diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbc3214c3..049e6bb38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,7 +80,7 @@ jobs: run: cargo bench -p trusted-server-core --bench html_processor_bench -- --test - name: Run host-target CLI tests - run: cargo test --package trusted-server-cli --target x86_64-unknown-linux-gnu + run: ./scripts/test-cli.sh - name: Verify Fastly WASM release build run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/.gitignore b/.gitignore index dabeec55d..a63ae5385 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ # EdgeZero local KV store (created by edgezero-adapter-axum framework) .edgezero/ +/dist/prebid/ # env .env* diff --git a/CLAUDE.md b/CLAUDE.md index 2c902a7c4..dd7f835d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,8 @@ cargo test-spin # Spin adapter route tests (native host) # Run host-target CLI tests (workspace default target is wasm32-wasip1) # Use your host triple, for example x86_64-unknown-linux-gnu on CI/Linux # or aarch64-apple-darwin on Apple Silicon macOS. +# Use the local helper (recommended): +# ./scripts/test-cli.sh cargo test --package trusted-server-cli --target # Format diff --git a/Cargo.lock b/Cargo.lock index 6e757bb5f..164b64faa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,9 +213,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -223,14 +223,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -3350,9 +3351,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -4001,9 +4002,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "libc", @@ -4023,9 +4024,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4363,9 +4364,11 @@ dependencies = [ "regex", "scraper", "serde", + "serde_json", "tempfile", "tokio", "toml", + "toml_edit", "trusted-server-core", "url", "which", diff --git a/Cargo.toml b/Cargo.toml index 29b7be332..ef6d76622 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,7 @@ temp-env = "0.3.6" tempfile = "3.24" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } toml = "1.1" +toml_edit = "0.23.10" tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } url = "2.5.8" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ee0e94185..eea9e75ae 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1175,6 +1175,9 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [proxy] + allowed_domains = ["*.example", "*.example.com"] + [ec] passphrase = "test-passphrase-at-least-32-bytes!!" @@ -1189,6 +1192,7 @@ mod tests { [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [integrations.datadome] enabled = true @@ -1240,6 +1244,9 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [proxy] + allowed_domains = ["*.example", "*.example.com"] + [ec] passphrase = "test-secret-key-32-bytes-minimum" @@ -1251,6 +1258,7 @@ mod tests { [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [auction] enabled = true diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index ce542a7c0..6a4c692f9 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -393,6 +393,9 @@ fn base_route_settings_toml() -> &'static str { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [proxy] + allowed_domains = ["*.example", "*.example.com"] + [ec] passphrase = "test-secret-key-32-bytes-minimum" @@ -408,7 +411,8 @@ fn prebid_integration_toml() -> &'static str { [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" - "# + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" + "# } fn create_test_settings() -> Settings { diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 189c5405a..00f111bfb 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -2,7 +2,7 @@ name = "trusted-server-cli" version = "0.1.0" authors = [] -edition = "2021" +edition = "2024" publish = false license = "Apache-2.0" @@ -22,9 +22,11 @@ log = { workspace = true } regex = { workspace = true } scraper = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } which = { workspace = true } diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index e0e54f2bd..88ae149f0 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -17,6 +17,8 @@ mod config_init; #[cfg(not(target_arch = "wasm32"))] mod error; #[cfg(not(target_arch = "wasm32"))] +mod prebid_bundle; +#[cfg(not(target_arch = "wasm32"))] mod run; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs new file mode 100644 index 000000000..005230006 --- /dev/null +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -0,0 +1,916 @@ +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde::Deserialize; +use toml_edit::{DocumentMut, Item, table, value}; + +pub(crate) type CliResult = Result; + +const NODE_MODULES_MISSING_HELP: &str = "Prebid bundling dependencies are missing. Run `cd crates/trusted-server-js/lib && npm ci`, then retry `ts prebid bundle`."; + +#[derive(Debug, clap::Args)] +pub(crate) struct PrebidBundleArgs { + /// Trusted Server config path. + #[arg(long, default_value = "trusted-server.toml")] + pub config: PathBuf, + /// Local output directory for generated Prebid bundle artifacts. + #[arg(long, default_value = "dist/prebid")] + pub out: PathBuf, +} + +fn report_error(message: impl Into) -> String { + message.into() +} + +fn cli_error(message: impl Into) -> CliResult { + Err(message.into()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PrebidBundleConfig { + pub adapters: Vec, + pub user_id_modules: Option>, + pub external_bundle_url: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PrebidBundleGenerateRequest { + pub js_lib_dir: PathBuf, + pub out_dir: PathBuf, + pub adapters: Vec, + pub user_id_modules: Option>, +} + +pub(crate) trait PrebidBundleGenerator { + fn generate( + &mut self, + request: &PrebidBundleGenerateRequest, + out: &mut dyn Write, + err: &mut dyn Write, + ) -> CliResult<()>; +} + +#[derive(Default)] +pub(crate) struct NpmPrebidBundleGenerator; + +impl PrebidBundleGenerator for NpmPrebidBundleGenerator { + fn generate( + &mut self, + request: &PrebidBundleGenerateRequest, + out: &mut dyn Write, + err: &mut dyn Write, + ) -> CliResult<()> { + ensure_local_build_prerequisites(&request.js_lib_dir)?; + + let args = npm_prebid_bundle_args(request); + + let output = Command::new("npm") + .args(&args) + .current_dir(&request.js_lib_dir) + .stdin(Stdio::null()) + .output() + .map_err(|error| { + report_error(format!( + "failed to run Prebid bundle generator with npm: {error}" + )) + })?; + + if !output.stdout.is_empty() { + out.write_all(&output.stdout).map_err(|error| { + report_error(format!("failed to forward generator stdout: {error}")) + })?; + } + + if !output.stderr.is_empty() { + err.write_all(&output.stderr).map_err(|error| { + report_error(format!("failed to forward generator stderr: {error}")) + })?; + } + + if output.status.success() { + Ok(()) + } else { + cli_error(format!( + "Prebid bundle generator exited with status {}", + output.status + )) + } + } +} + +fn npm_prebid_bundle_args(request: &PrebidBundleGenerateRequest) -> Vec { + let mut args = 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 +} + +#[derive(Debug, Deserialize)] +struct PrebidBundleManifest { + sha256: String, + sri: String, + filename: String, +} + +pub(crate) fn run_bundle( + args: &PrebidBundleArgs, + generator: &mut dyn PrebidBundleGenerator, + out: &mut dyn Write, + err: &mut dyn Write, +) -> CliResult<()> { + let config = load_bundle_config(&args.config)?; + let current_dir = env::current_dir() + .map_err(|error| report_error(format!("failed to read current directory: {error}")))?; + let js_lib_dir = find_js_lib_dir(¤t_dir)?; + let out_dir = resolve_output_dir(¤t_dir, &args.out); + ensure_output_dir_writable(&out_dir)?; + + let request = PrebidBundleGenerateRequest { + js_lib_dir, + out_dir: out_dir.clone(), + adapters: config.adapters, + user_id_modules: config.user_id_modules, + }; + + generator.generate(&request, out, err)?; + + let manifest_path = out_dir.join("manifest.json"); + let manifest = load_manifest(&manifest_path)?; + patch_config_metadata(&args.config, &manifest.sha256, &manifest.sri)?; + + writeln!( + out, + "Built Prebid bundle: {}", + out_dir.join(&manifest.filename).display() + ) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + writeln!(out, "Manifest: {}", manifest_path.display()) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + writeln!(out, "Updated config: {}", args.config.display()) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + + if config.external_bundle_url.is_none() { + writeln!( + out, + "Next: upload the bundle and set integrations.prebid.external_bundle_url to its HTTPS URL." + ) + } else { + writeln!( + out, + "Next: upload the bundle and update integrations.prebid.external_bundle_url if the hosted filename changed." + ) + } + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + + Ok(()) +} + +pub(crate) fn load_bundle_config(config_path: &Path) -> CliResult { + let contents = fs::read_to_string(config_path).map_err(|error| { + report_error(format!( + "missing {}: run `ts config init` or pass --config : {error}", + config_path.display() + )) + })?; + let root: toml::Value = toml::from_str(&contents).map_err(|error| { + report_error(format!( + "invalid TOML in {}: {error}", + config_path.display() + )) + })?; + + let prebid = root + .get("integrations") + .and_then(|integrations| integrations.get("prebid")) + .ok_or_else(|| { + report_error(format!( + "{} is missing [integrations.prebid]", + config_path.display() + )) + })?; + let bundle = prebid.get("bundle").ok_or_else(|| { + report_error(format!( + "{} is missing [integrations.prebid.bundle]", + config_path.display() + )) + })?; + + let adapters = read_required_string_array( + bundle, + "adapters", + "integrations.prebid.bundle.adapters", + config_path, + )?; + if adapters.is_empty() { + return cli_error(format!( + "{} must define at least one integrations.prebid.bundle.adapters entry", + config_path.display() + )); + } + + let user_id_modules = read_optional_string_array( + bundle, + "user_id_modules", + "integrations.prebid.bundle.user_id_modules", + config_path, + )?; + if matches!(user_id_modules.as_ref(), Some(modules) if modules.is_empty()) { + return cli_error(format!( + "{} integrations.prebid.bundle.user_id_modules must not be empty when present", + config_path.display() + )); + } + + let external_bundle_url = prebid + .get("external_bundle_url") + .and_then(toml::Value::as_str) + .map(str::to_string); + + Ok(PrebidBundleConfig { + adapters, + user_id_modules, + external_bundle_url, + }) +} + +fn read_required_string_array( + table: &toml::Value, + key: &str, + field_name: &str, + config_path: &Path, +) -> 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 { + return cli_error(format!( + "{} {field_name} must be an array of non-empty strings", + 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() + )); + } + strings.push(trimmed.to_string()); + } + + Ok(strings) +} + +fn ensure_local_build_prerequisites(js_lib_dir: &Path) -> CliResult<()> { + which::which("npm").map_err(|error| { + report_error(format!( + "npm is required to build the Prebid bundle but was not found on PATH: {error}" + )) + })?; + + ensure_file_exists( + &js_lib_dir.join("package.json"), + "Prebid bundle package manifest", + )?; + ensure_file_exists( + &js_lib_dir.join("build-prebid-external.mjs"), + "Prebid external bundle generator", + )?; + + let node_modules = js_lib_dir.join("node_modules"); + if !node_modules.is_dir() { + return cli_error(NODE_MODULES_MISSING_HELP); + } + + Ok(()) +} + +fn ensure_file_exists(path: &Path, description: &str) -> CliResult<()> { + if path.is_file() { + Ok(()) + } else { + cli_error(format!("missing {description}: {}", path.display())) + } +} + +fn find_js_lib_dir(start: &Path) -> CliResult { + for ancestor in start.ancestors() { + let candidate = ancestor.join("crates/trusted-server-js/lib"); + if is_js_lib_dir(&candidate) { + return Ok(candidate); + } + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let candidate = manifest_dir + .join("../..") + .join("crates/trusted-server-js/lib"); + if is_js_lib_dir(&candidate) { + return candidate.canonicalize().map_err(|error| { + report_error(format!( + "failed to resolve JS library directory {}: {error}", + candidate.display() + )) + }); + } + + cli_error( + "failed to locate crates/trusted-server-js/lib; run `ts prebid bundle` from the Trusted Server repository", + ) +} + +fn is_js_lib_dir(path: &Path) -> bool { + path.join("package.json").is_file() && path.join("build-prebid-external.mjs").is_file() +} + +fn resolve_output_dir(current_dir: &Path, out_dir: &Path) -> PathBuf { + if out_dir.is_absolute() { + out_dir.to_path_buf() + } else { + current_dir.join(out_dir) + } +} + +fn ensure_output_dir_writable(out_dir: &Path) -> CliResult<()> { + if out_dir.exists() && !out_dir.is_dir() { + return cli_error(format!( + "Prebid bundle output path {} exists but is not a directory", + out_dir.display() + )); + } + + fs::create_dir_all(out_dir).map_err(|error| { + report_error(format!( + "failed to create Prebid bundle output directory {}: {error}", + out_dir.display() + )) + })?; + + let probe = out_dir.join(format!( + ".ts-prebid-bundle-write-test-{}", + std::process::id() + )); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .map_err(|error| { + report_error(format!( + "Prebid bundle output directory {} is not writable: {error}", + out_dir.display() + )) + })?; + fs::remove_file(&probe).map_err(|error| { + report_error(format!( + "failed to remove Prebid bundle output probe {}: {error}", + probe.display() + )) + })?; + + Ok(()) +} + +fn load_manifest(path: &Path) -> CliResult { + let contents = fs::read_to_string(path).map_err(|error| { + report_error(format!( + "failed to read generated Prebid manifest {}: {error}", + path.display() + )) + })?; + let manifest: PrebidBundleManifest = serde_json::from_str(&contents).map_err(|error| { + report_error(format!( + "failed to parse generated Prebid manifest {}: {error}", + path.display() + )) + })?; + + if manifest.filename.trim().is_empty() { + return cli_error(format!( + "generated Prebid manifest {} is missing filename", + path.display() + )); + } + if manifest.sha256.len() != 64 || !manifest.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return cli_error(format!( + "generated Prebid manifest {} has invalid sha256", + path.display() + )); + } + if !manifest.sri.starts_with("sha384-") { + return cli_error(format!( + "generated Prebid manifest {} has invalid sri", + path.display() + )); + } + + Ok(manifest) +} + +fn patch_config_metadata(config_path: &Path, sha256: &str, sri: &str) -> CliResult<()> { + let contents = fs::read_to_string(config_path).map_err(|error| { + report_error(format!( + "failed to read config {} for metadata update: {error}", + config_path.display() + )) + })?; + let mut document = contents.parse::().map_err(|error| { + report_error(format!( + "failed to parse config {} for metadata update: {error}", + config_path.display() + )) + })?; + + if !document.contains_key("integrations") { + document.insert("integrations", table()); + } + let integrations = table_like_mut( + document + .get_mut("integrations") + .expect("should have integrations table"), + "integrations", + config_path, + )?; + + if !integrations.contains_key("prebid") { + integrations.insert("prebid", table()); + } + let prebid = table_like_mut( + integrations + .get_mut("prebid") + .expect("should have prebid table"), + "integrations.prebid", + config_path, + )?; + + prebid.insert("external_bundle_sha256", value(sha256)); + prebid.insert("external_bundle_sri", value(sri)); + + write_atomic(config_path, &document.to_string()) +} + +fn table_like_mut<'a>( + item: &'a mut Item, + field_name: &str, + config_path: &Path, +) -> CliResult<&'a mut dyn toml_edit::TableLike> { + item.as_table_like_mut().ok_or_else(|| { + report_error(format!( + "{} {field_name} must be a TOML table", + config_path.display() + )) + }) +} + +fn write_atomic(path: &Path, contents: &str) -> CliResult<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|error| { + report_error(format!( + "failed to create config parent directory {}: {error}", + parent.display() + )) + })?; + + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("trusted-server.toml"); + let tmp_path = parent.join(format!(".{filename}.tmp-{}", std::process::id())); + + fs::write(&tmp_path, contents).map_err(|error| { + report_error(format!( + "failed to write temporary config {}: {error}", + tmp_path.display() + )) + })?; + fs::rename(&tmp_path, path).map_err(|error| { + let _ = fs::remove_file(&tmp_path); + report_error(format!( + "failed to replace config {} with {}: {error}", + path.display(), + tmp_path.display() + )) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_config(contents: &str) -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + fs::write(&path, contents).expect("should write config"); + (temp, path) + } + + fn valid_config() -> String { + r#" +[integrations.prebid] +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"] +"# + .to_string() + } + + #[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() + ]) + ); + assert_eq!( + config.external_bundle_url.as_deref(), + Some("https://assets.example.com/prebid/trusted-prebid-old.js") + ); + } + + #[test] + fn bundle_config_loader_allows_missing_user_id_modules() { + let (_temp, path) = write_config( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[integrations.prebid.bundle] +adapters = ["rubicon"] +"#, + ); + + let config = load_bundle_config(&path).expect("should load bundle config"); + + assert_eq!(config.adapters, ["rubicon"]); + assert_eq!(config.user_id_modules, None); + } + + #[test] + fn bundle_config_loader_rejects_missing_prebid_block() { + let (_temp, path) = write_config("[publisher]\ndomain = \"example.com\"\n"); + + let error = load_bundle_config(&path).expect_err("should reject missing prebid block"); + + assert!( + error.to_string().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" +"#, + ); + + let error = load_bundle_config(&path).expect_err("should reject missing bundle block"); + + assert!( + error + .to_string() + .contains("missing [integrations.prebid.bundle]"), + "error should explain missing bundle block: {error:?}" + ); + } + + #[test] + fn bundle_config_loader_rejects_empty_adapters() { + let (_temp, path) = write_config( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[integrations.prebid.bundle] +adapters = [] +"#, + ); + + let error = load_bundle_config(&path).expect_err("should reject empty adapters"); + + assert!( + error.to_string().contains("at least one"), + "error should explain empty adapters: {error:?}" + ); + } + + #[test] + fn bundle_config_loader_rejects_malformed_adapters() { + let (_temp, path) = write_config( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[integrations.prebid.bundle] +adapters = ["rubicon", 123] +"#, + ); + + let error = load_bundle_config(&path).expect_err("should reject malformed adapters"); + + assert!( + error.to_string().contains("array of non-empty strings"), + "error should explain malformed adapters: {error:?}" + ); + } + + #[test] + fn output_dir_validation_rejects_existing_file() { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + let out_path = temp.path().join("prebid"); + fs::write(&out_path, "not a directory").expect("should write file"); + + let error = + ensure_output_dir_writable(&out_path).expect_err("should reject output path file"); + + assert!( + error.to_string().contains("not a directory"), + "error should explain invalid output path: {error:?}" + ); + } + + #[test] + fn output_dir_validation_creates_writable_directory() { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + let out_path = temp.path().join("dist/prebid"); + + ensure_output_dir_writable(&out_path).expect("should create output dir"); + + assert!(out_path.is_dir(), "should create output directory"); + } + + #[test] + fn npm_prebid_bundle_args_include_user_id_modules_when_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(), "kargo".to_string()], + user_id_modules: Some(vec!["sharedIdSystem".to_string()]), + }; + + assert_eq!( + npm_prebid_bundle_args(&request), + [ + "run", + "build:prebid-external", + "--", + "--adapters", + "rubicon,kargo", + "--user-id-modules", + "sharedIdSystem", + "--out", + "/tmp/prebid", + ], + "should pass configured adapters, user ID modules, and 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" + ); + } + + #[test] + fn patch_config_metadata_writes_hash_and_sri() { + let (_temp, path) = write_config(&valid_config()); + let sha256 = "a".repeat(64); + let sri = "sha384-abc"; + + patch_config_metadata(&path, &sha256, sri).expect("should patch config metadata"); + + let contents = fs::read_to_string(&path).expect("should read patched config"); + let value: toml::Value = toml::from_str(&contents).expect("should parse patched config"); + let prebid = value + .get("integrations") + .and_then(|integrations| integrations.get("prebid")) + .expect("should have prebid table"); + assert_eq!( + prebid + .get("external_bundle_url") + .and_then(toml::Value::as_str), + Some("https://assets.example.com/prebid/trusted-prebid-old.js"), + "should preserve external bundle URL" + ); + assert_eq!( + prebid + .get("external_bundle_sha256") + .and_then(toml::Value::as_str), + Some(sha256.as_str()), + "should write sha256" + ); + assert_eq!( + prebid + .get("external_bundle_sri") + .and_then(toml::Value::as_str), + Some(sri), + "should write SRI" + ); + } + + struct FakeGenerator { + generate_error: Option, + generate_calls: Vec, + write_manifest: bool, + } + + impl PrebidBundleGenerator for FakeGenerator { + fn generate( + &mut self, + request: &PrebidBundleGenerateRequest, + out: &mut dyn Write, + err: &mut dyn Write, + ) -> CliResult<()> { + self.generate_calls.push(request.clone()); + + out.write_all(b"generator stdout\n") + .expect("should capture generator stdout"); + err.write_all(b"generator stderr\n") + .expect("should capture generator stderr"); + + 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"); + } + + if let Some(error) = &self.generate_error { + cli_error(error.clone()) + } else { + Ok(()) + } + } + } + + #[test] + fn run_bundle_forwards_generator_output_to_stdio() { + let (_temp, config_path) = write_config(&valid_config()); + let _out_root = tempfile::tempdir().expect("should create temp dir"); + let out_dir = _out_root.path().join("prebid"); + + let mut generator = FakeGenerator { + generate_error: None, + generate_calls: Vec::new(), + write_manifest: true, + }; + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = PrebidBundleArgs { + config: config_path, + out: out_dir.clone(), + }; + + run_bundle(&args, &mut generator, &mut out, &mut err).expect("should run bundle command"); + + let output = String::from_utf8(out).expect("stdout should be valid utf8"); + assert!(output.contains("generator stdout")); + let stderr = String::from_utf8(err).expect("stderr should be valid utf8"); + assert!(stderr.contains("generator stderr")); + + assert_eq!(generator.generate_calls.len(), 1); + assert_eq!(generator.generate_calls[0].adapters, ["rubicon", "kargo"]); + + let patched = fs::read_to_string(&args.config).expect("should read patched config"); + assert!(patched.contains(&format!("external_bundle_sha256 = \"{}\"", "b".repeat(64)))); + assert!(patched.contains("external_bundle_sri = \"sha384-test\"")); + } + + #[test] + fn run_bundle_does_not_patch_config_when_generation_fails() { + 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 out_dir = _out_root.path().join("prebid"); + + let mut generator = FakeGenerator { + generate_error: Some("builder failed".to_string()), + generate_calls: Vec::new(), + write_manifest: false, + }; + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = PrebidBundleArgs { + config: config_path, + out: out_dir, + }; + + let error = run_bundle(&args, &mut generator, &mut out, &mut err) + .expect_err("should propagate generator failure"); + + assert!(error.to_string().contains("builder failed")); + assert!(fs::read_to_string(&args.config).expect("should read config") == original_config); + } + + #[test] + fn missing_node_modules_fails_with_npm_ci_instruction() { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + fs::write(temp.path().join("package.json"), "{}").expect("should write package manifest"); + fs::write(temp.path().join("build-prebid-external.mjs"), "") + .expect("should write generator"); + + let error = ensure_local_build_prerequisites(temp.path()) + .expect_err("should reject missing node modules"); + + assert!( + error.to_string().contains("npm ci"), + "error should instruct npm ci: {error:?}" + ); + } +} diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 83491022c..44e18c861 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -9,6 +9,7 @@ use trusted_server_core::config::TrustedServerAppConfig; use crate::audit::browser_collector::BrowserAuditCollector; use crate::config_init::{run_config_init, ConfigInitArgs}; +use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundle}; #[derive(Debug, Parser)] #[command(name = "ts", about = "Trusted Server CLI")] @@ -30,6 +31,8 @@ enum Command { Config(ConfigCommand), /// Deploy the project through a target adapter. Deploy(DeployArgs), + /// Trusted Server Prebid commands. + Prebid(PrebidArgs), /// Provision platform resources through a target adapter. Provision(ProvisionArgs), /// Serve the project locally through a target adapter. @@ -69,12 +72,24 @@ enum ConfigCommand { Validate(ConfigValidateArgs), } +#[derive(Debug, clap::Args)] +struct PrebidArgs { + #[command(subcommand)] + command: PrebidCommand, +} + +#[derive(Debug, Subcommand)] +enum PrebidCommand { + /// Generate a local external Prebid bundle and update config metadata. + Bundle(PrebidBundleArgs), +} + /// Run the CLI using process arguments. /// /// # Errors /// /// Returns an error when command parsing, config validation, `EdgeZero` -/// delegation, audit collection, or config initialization fails. +/// delegation, audit collection, config initialization, or Prebid bundle generation fails. pub fn run_from_env() -> Result<(), String> { dispatch(Args::parse()) } @@ -104,6 +119,16 @@ fn dispatch(args: Args) -> Result<(), String> { edgezero_cli::run_config_validate_typed::(&args) } Command::Deploy(args) => edgezero_cli::run_deploy(&args), + Command::Prebid(prebid) => { + let mut generator = NpmPrebidBundleGenerator; + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + match prebid.command { + PrebidCommand::Bundle(args) => { + run_bundle(&args, &mut generator, &mut stdout, &mut stderr) + } + } + } Command::Provision(args) => edgezero_cli::run_provision(&args), Command::Serve(args) => edgezero_cli::run_serve(&args), } @@ -282,4 +307,45 @@ mod tests { let default_validate = ConfigValidateArgs::default(); assert_eq!(validate.manifest, default_validate.manifest); } + + #[test] + fn prebid_bundle_defaults_match_spec() { + let args = parse(&["ts", "prebid", "bundle"]); + let Command::Prebid(prebid) = args.command else { + panic!("expected prebid command"); + }; + let PrebidCommand::Bundle(bundle) = prebid.command; + assert_eq!(bundle.config, PathBuf::from("trusted-server.toml")); + assert_eq!(bundle.out, PathBuf::from("dist/prebid")); + } + + #[test] + fn prebid_bundle_accepts_custom_paths() { + let args = parse(&[ + "ts", + "prebid", + "bundle", + "--config", + "publisher.toml", + "--out", + "build/prebid", + ]); + let Command::Prebid(prebid) = args.command else { + panic!("expected prebid command"); + }; + let PrebidCommand::Bundle(bundle) = prebid.command; + assert_eq!(bundle.config, PathBuf::from("publisher.toml")); + assert_eq!(bundle.out, PathBuf::from("build/prebid")); + } + + #[test] + fn prebid_bundle_does_not_accept_adapter_option() { + let error = Args::try_parse_from(["ts", "prebid", "bundle", "--adapter", "fastly"]) + .expect_err("should reject prebid adapter option"); + assert!( + error.to_string().contains("unexpected argument") + || error.to_string().contains("Found argument"), + "error should explain unsupported option" + ); + } } diff --git a/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 6f323456f..3049a1115 100644 --- a/crates/trusted-server-core/README.md +++ b/crates/trusted-server-core/README.md @@ -41,7 +41,7 @@ Helpers: JS bundles (served by publisher module): - Dynamic endpoint: `/static/tsjs=tsjs-unified.min.js?v=` - - At build time, each integration is compiled as a separate IIFE (`tsjs-core.js`, `tsjs-prebid.js`, `tsjs-creative.js`, etc.) + - At build time, embedded integrations are compiled as separate IIFEs (`tsjs-core.js`, `tsjs-creative.js`, etc.); Prebid is generated externally and served through `/integrations/prebid/bundle.js`. - At runtime, the server concatenates `tsjs-core.js` + enabled integration modules based on `IntegrationRegistry` config - The URL filename is fixed for backward compatibility; the `?v=` hash changes when modules change diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index b0b0545fd..0ade5d1c8 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -92,8 +92,10 @@ mod tests { fn settings_with_auction_config(auction_config: &str) -> Settings { let settings_str = format!("{}\n{auction_config}", crate_test_settings_str()); - Settings::from_toml(&settings_str) - .expect("should parse auction provider validation test settings") + let mut settings = Settings::from_toml(&settings_str) + .expect("should parse auction provider validation test settings"); + settings.proxy.allowed_domains = vec!["*.example".to_string(), "*.example.com".to_string()]; + settings } fn assert_orchestrator_error_contains(settings: &Settings, expected: &str) { diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 4499db7e3..8863a7831 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -18,12 +18,27 @@ use crate::error::TrustedServerError; use crate::integrations::{ adserver_mock::AdServerMockConfig, aps::ApsConfig, datadome::DataDomeConfig, didomi::DidomiIntegrationConfig, google_tag_manager::GoogleTagManagerConfig, gpt::GptConfig, - lockr::LockrConfig, nextjs::NextJsIntegrationConfig, permutive::PermutiveConfig, prebid, - sourcepoint::SourcepointConfig, testlight::TestlightConfig, + lockr::LockrConfig, nextjs::NextJsIntegrationConfig, osano::OsanoConfig, + permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; use crate::settings::{IntegrationConfig, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; +const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ + "prebid", + "aps", + "adserver_mock", + "testlight", + "nextjs", + "permutive", + "lockr", + "didomi", + "sourcepoint", + "osano", + "google_tag_manager", + "datadome", + "gpt", +]; /// Typed app-config root used by the `ts` CLI. /// @@ -118,7 +133,8 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report Result, Report> { - let mut enabled_auction_providers = HashSet::new(); + let mut enabled_auction_providers = + HashSet::with_capacity(DEPLOY_VALIDATED_INTEGRATION_IDS.len()); if validate_prebid(settings)? { enabled_auction_providers.insert("prebid"); @@ -135,6 +151,7 @@ fn validate_enabled_integrations( validate_integration::(settings, "lockr")?; validate_integration::(settings, "didomi")?; validate_integration::(settings, "sourcepoint")?; + validate_integration::(settings, "osano")?; validate_integration::(settings, "google_tag_manager")?; validate_integration::(settings, "datadome")?; validate_integration::(settings, "gpt")?; @@ -199,7 +216,10 @@ mod tests { use crate::test_support::tests::crate_test_settings_str; fn valid_settings() -> Settings { - Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") + let mut settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.proxy.allowed_domains = vec!["*.example".to_string(), "*.example.com".to_string()]; + settings } #[test] @@ -261,6 +281,55 @@ password = "production-admin-password-32-bytes" ); } + #[test] + fn deploy_validation_rejects_external_prebid_bundle_without_proxy_allowed_domains() { + let mut settings = valid_settings(); + settings.proxy.allowed_domains.clear(); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject external Prebid bundle without proxy allowlist"); + + assert!( + err.to_string().contains("proxy.allowed_domains"), + "error should mention proxy.allowed_domains: {err:?}" + ); + } + + #[test] + fn deploy_validation_covers_registered_integration_builders() { + let validated_ids: HashSet<&'static str> = + DEPLOY_VALIDATED_INTEGRATION_IDS.iter().copied().collect(); + let missing_ids = crate::integrations::registered_builder_ids() + .filter(|id| !validated_ids.contains(id)) + .collect::>(); + + assert!( + missing_ids.is_empty(), + "deploy validation should cover all registered integration builders: {missing_ids:?}" + ); + } + + #[test] + fn deploy_validation_rejects_invalid_osano_config() { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "osano", + &serde_json::json!({ "enabled": true, "typo": true }), + ) + .expect("should insert Osano config"); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject invalid Osano config during deploy validation"); + let error_text = format!("{err:?}"); + + assert!( + error_text.contains("osano") || error_text.contains("typo"), + "error should mention Osano or the invalid field: {err:?}" + ); + } + #[test] fn validate_trait_reports_deploy_errors() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 84336e3e4..58a429e7a 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -678,7 +678,7 @@ mod tests { use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::platform::test_support::noop_services; - use crate::test_support::tests::crate_test_settings_str; + use crate::test_support::tests::create_test_settings; use http::Method; use std::io::Cursor; @@ -1499,7 +1499,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= } fn make_settings() -> Settings { - Settings::from_toml(&crate_test_settings_str()).expect("should parse settings") + create_test_settings() } fn config_from_settings( diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 3678f949c..45f734ad6 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -273,21 +273,64 @@ pub(crate) async fn collect_response_bounded( } } -type IntegrationBuilder = +type IntegrationBuilderFn = fn(&Settings) -> Result, Report>; +pub(crate) struct IntegrationBuilder { + id: &'static str, + build: IntegrationBuilderFn, +} + pub(crate) fn builders() -> &'static [IntegrationBuilder] { &[ - prebid::register, - testlight::register, - nextjs::register, - permutive::register, - lockr::register, - didomi::register, - sourcepoint::register, - osano::register, - google_tag_manager::register, - datadome::register, - gpt::register, + IntegrationBuilder { + id: "prebid", + build: prebid::register, + }, + IntegrationBuilder { + id: "testlight", + build: testlight::register, + }, + IntegrationBuilder { + id: "nextjs", + build: nextjs::register, + }, + IntegrationBuilder { + id: "permutive", + build: permutive::register, + }, + IntegrationBuilder { + id: "lockr", + build: lockr::register, + }, + IntegrationBuilder { + id: "didomi", + build: didomi::register, + }, + IntegrationBuilder { + id: "sourcepoint", + build: sourcepoint::register, + }, + IntegrationBuilder { + id: "osano", + build: osano::register, + }, + IntegrationBuilder { + id: "google_tag_manager", + build: google_tag_manager::register, + }, + IntegrationBuilder { + id: "datadome", + build: datadome::register, + }, + IntegrationBuilder { + id: "gpt", + build: gpt::register, + }, ] } + +#[cfg(test)] +pub(crate) fn registered_builder_ids() -> impl Iterator { + builders().iter().map(|builder| builder.id) +} diff --git a/crates/trusted-server-core/src/integrations/osano.rs b/crates/trusted-server-core/src/integrations/osano.rs index f2e006c72..5028cd7dd 100644 --- a/crates/trusted-server-core/src/integrations/osano.rs +++ b/crates/trusted-server-core/src/integrations/osano.rs @@ -18,6 +18,7 @@ const OSANO_INTEGRATION_ID: &str = "osano"; /// Configuration for the Osano consent mirror integration. #[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] pub struct OsanoConfig { /// Whether the Osano browser consent mirror is enabled. #[serde(default)] @@ -52,7 +53,7 @@ pub fn register( mod tests { use serde_json::json; - use super::register; + use super::{register, OsanoConfig}; use crate::test_support::tests::create_test_settings; #[test] @@ -93,4 +94,23 @@ mod tests { "Osano v1 should not inject HTML from Rust" ); } + + #[test] + fn config_rejects_unknown_fields() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("osano", &json!({ "enabled": true, "typo": true })) + .expect("should insert osano config"); + + let err = settings + .integration_config::("osano") + .expect_err("should reject unknown Osano config fields"); + let error_text = format!("{err:?}"); + + assert!( + error_text.contains("typo") || error_text.contains("unknown field"), + "error should mention the unknown field: {err:?}" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d2bceb43a..3d64d8157 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2,16 +2,6 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; -use edgezero_core::body::Body as EdgeBody; -use error_stack::{Report, ResultExt}; -use http::header::HeaderValue; -use http::{header, Method, StatusCode}; -use serde::{Deserialize, Serialize}; -use serde_json::Value as Json; -use url::Url; -use validator::Validate; - use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, @@ -35,10 +25,32 @@ use crate::openrtb::{ use crate::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, }; +use crate::proxy::{is_host_allowed, proxy_request, ProxyRequestConfig}; use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; +use async_trait::async_trait; +use base64::{ + engine::general_purpose::{ + STANDARD as BASE64_STANDARD, STANDARD_NO_PAD as BASE64_STANDARD_NO_PAD, + }, + Engine as _, +}; +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt}; +use http::header::HeaderValue; +use http::{header, Method, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use url::Url; +use validator::{Validate, ValidationError}; const PREBID_INTEGRATION_ID: &str = "prebid"; +const PREBID_BUNDLE_ROUTE: &str = "/integrations/prebid/bundle.js"; +const PREBID_BUNDLE_CONTENT_TYPE: &str = "application/javascript; charset=utf-8"; +const PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; +const PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL: &str = + "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400"; +const PREBID_BUNDLE_ERROR_CACHE_CONTROL: &str = "no-store"; const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; const BIDDER_PARAMS_KEY: &str = "bidderParams"; const ZONE_KEY: &str = "zone"; @@ -107,6 +119,18 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + /// Absolute HTTPS URL of the generated external Prebid bundle. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_url"))] + pub external_bundle_url: Option, + /// Optional hex SHA-256 of the exact external bundle bytes. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sha256"))] + pub external_bundle_sha256: Option, + /// Optional browser Subresource Integrity value for the first-party script. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sri"))] + pub external_bundle_sri: Option, /// Bidders that should run client-side in the browser via native Prebid.js /// adapters instead of being routed through the server-side auction. /// @@ -207,6 +231,7 @@ pub fn validate_config_for_startup( return Ok(None); }; BidParamOverrideEngine::try_from_config(&config)?; + validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?; Ok(Some(config)) } @@ -266,6 +291,170 @@ fn default_script_patterns() -> Vec { .collect() } +fn validate_external_bundle_url(value: &str) -> Result<(), ValidationError> { + let url = Url::parse(value).map_err(|_| { + let mut err = ValidationError::new("invalid_external_bundle_url"); + err.message = Some("external_bundle_url must be a valid absolute URL".into()); + err + })?; + + if url.scheme() != "https" { + let mut err = ValidationError::new("invalid_external_bundle_scheme"); + err.message = Some("external_bundle_url must use https".into()); + return Err(err); + } + + if url.host_str().is_none() { + let mut err = ValidationError::new("missing_external_bundle_host"); + err.message = Some("external_bundle_url must include a host".into()); + return Err(err); + } + + Ok(()) +} + +fn validate_external_bundle_sha256(value: &str) -> Result<(), ValidationError> { + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(()); + } + + let mut err = ValidationError::new("invalid_external_bundle_sha256"); + err.message = Some("external_bundle_sha256 must be a 64-character hex SHA-256".into()); + Err(err) +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExternalBundleSriAlgorithm { + Sha256, + Sha384, + Sha512, +} + +impl ExternalBundleSriAlgorithm { + fn parse(value: &str) -> Option { + match value { + "sha256" => Some(Self::Sha256), + "sha384" => Some(Self::Sha384), + "sha512" => Some(Self::Sha512), + _ => None, + } + } + + fn expected_digest_len(self) -> usize { + match self { + Self::Sha256 => 32, + Self::Sha384 => 48, + Self::Sha512 => 64, + } + } +} + +fn external_bundle_sri_validation_error(message: &'static str) -> ValidationError { + let mut err = ValidationError::new("invalid_external_bundle_sri"); + err.message = Some(message.into()); + err +} + +fn parse_external_bundle_sri(value: &str) -> Result<(), ValidationError> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed != value { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri must be non-empty with no surrounding whitespace", + )); + } + + for token in trimmed.split_ascii_whitespace() { + let Some((algorithm_raw, digest_raw)) = token.split_once('-') else { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri entries must use algorithm-digest format", + )); + }; + + let Some(algorithm) = ExternalBundleSriAlgorithm::parse(algorithm_raw) else { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri must use sha256, sha384, or sha512", + )); + }; + + if digest_raw.is_empty() { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri digest must be non-empty", + )); + } + + let digest = BASE64_STANDARD + .decode(digest_raw) + .or_else(|_| BASE64_STANDARD_NO_PAD.decode(digest_raw)) + .map_err(|_| { + external_bundle_sri_validation_error("external_bundle_sri digest must be base64") + })?; + + if digest.len() != algorithm.expected_digest_len() { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri digest length does not match its algorithm", + )); + } + } + + Ok(()) +} + +fn validate_external_bundle_sri(value: &str) -> Result<(), ValidationError> { + parse_external_bundle_sri(value) +} + +fn validate_external_bundle_config( + config: &PrebidIntegrationConfig, + allowed_domains: &[String], +) -> Result<(), Report> { + let url = config.external_bundle_url.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url is required when prebid is enabled" + .to_string(), + }) + })?; + + let parsed = Url::parse(url).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must be a valid absolute URL" + .to_string(), + }) + })?; + + if parsed.scheme() != "https" { + return Err(Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must use https".to_string(), + })); + } + + let host = parsed.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must include a host".to_string(), + }) + })?; + + if allowed_domains.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: + "proxy.allowed_domains must include the external Prebid bundle host when integrations.prebid.external_bundle_url is configured" + .to_string(), + })); + } + + if !allowed_domains + .iter() + .any(|pattern| is_host_allowed(host, pattern)) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "integrations.prebid.external_bundle_url host `{host}` is not permitted by proxy.allowed_domains" + ), + })); + } + + Ok(()) +} + pub struct PrebidIntegration { config: PrebidIntegrationConfig, engine: Arc, @@ -362,16 +551,185 @@ impl PrebidIntegration { http::Response::builder() .status(StatusCode::OK) - .header( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ) + .header(header::CONTENT_TYPE, PREBID_BUNDLE_CONTENT_TYPE) .header(header::CACHE_CONTROL, "public, max-age=31536000") .body(EdgeBody::from(body)) .change_context(TrustedServerError::Prebid { message: "Failed to build Prebid script handler response".to_string(), }) } + + fn external_bundle_script_src(&self) -> String { + match self.config.external_bundle_sha256.as_deref() { + Some(sha256) => format!("{PREBID_BUNDLE_ROUTE}?v={sha256}"), + None => PREBID_BUNDLE_ROUTE.to_string(), + } + } + + fn external_bundle_script_tag(&self) -> String { + let src = self.external_bundle_script_src(); + let integrity = self + .config + .external_bundle_sri + .as_deref() + .map(|value| format!(" integrity=\"{}\"", escape_html_attr(value))) + .unwrap_or_default(); + + format!("") + } + + fn is_managed_external(&self) -> bool { + self.config.external_bundle_url.is_some() + } + + fn external_bundle_request_cache_mode( + &self, + req: &http::Request, + ) -> Result, Report> { + let versions = req + .uri() + .query() + .map(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .filter(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()) + .collect::>() + }) + .unwrap_or_default(); + + if versions.len() > 1 { + return Ok(None); + } + + let requested_version = versions.first().map(String::as_str); + match ( + self.config.external_bundle_sha256.as_deref(), + requested_version, + ) { + (None, Some(_)) => Ok(None), + (Some(expected), Some(actual)) if expected != actual => Ok(None), + (Some(_), Some(_)) => Ok(Some(ExternalBundleCacheMode::Immutable)), + _ => Ok(Some(ExternalBundleCacheMode::Revalidate)), + } + } + + fn apply_external_bundle_headers( + &self, + response: &mut http::Response, + mode: ExternalBundleCacheMode, + ) { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(PREBID_BUNDLE_CONTENT_TYPE), + ); + + match mode { + ExternalBundleCacheMode::Immutable => { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL), + ); + if let Some(sha256) = self.config.external_bundle_sha256.as_deref() { + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&format!("\"sha256:{sha256}\"")) + .expect("should build etag header"), + ); + } + } + ExternalBundleCacheMode::Revalidate => { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL), + ); + if let Some(sha256) = self.config.external_bundle_sha256.as_deref() { + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&format!("\"sha256:{sha256}\"")) + .expect("should build etag header"), + ); + } + } + } + } + + fn sanitize_external_bundle_response( + &self, + response: http::Response, + mode: ExternalBundleCacheMode, + ) -> http::Response { + let status = response.status(); + let content_encoding = response.headers().get(header::CONTENT_ENCODING).cloned(); + let body = response.into_body(); + + let mut sanitized = http::Response::builder() + .status(status) + .body(body) + .expect("should build sanitized response"); + + if let Some(content_encoding) = content_encoding { + sanitized + .headers_mut() + .insert(header::CONTENT_ENCODING, content_encoding); + } + + if status == StatusCode::OK { + self.apply_external_bundle_headers(&mut sanitized, mode); + } else { + sanitized.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(PREBID_BUNDLE_ERROR_CACHE_CONTROL), + ); + } + + sanitized + } + + async fn handle_external_bundle( + &self, + settings: &Settings, + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let Some(cache_mode) = self.external_bundle_request_cache_mode(&req)? else { + return Ok(http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("Not Found")) + .expect("should build not found response")); + }; + + let target_url = self.config.external_bundle_url.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: + "integrations.prebid.external_bundle_url is required when prebid is enabled" + .to_string(), + }) + })?; + + let proxy_config = ProxyRequestConfig::new(target_url) + .without_ec_id() + .without_forward_headers() + .with_streaming() + .with_allowed_domains(&settings.proxy.allowed_domains) + .with_https_only(); + + let response = proxy_request(settings, req, proxy_config, services).await?; + Ok(self.sanitize_external_bundle_response(response, cache_mode)) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExternalBundleCacheMode { + Immutable, + Revalidate, +} + +fn escape_html_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") } fn build( @@ -383,6 +741,8 @@ fn build( return Ok(None); }; + validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?; + // Warn about bidders that appear in both lists — this is likely a config // mistake. A bidder should be in either `bidders` (server-side) or // `client_side_bidders` (browser-side), not both. @@ -417,7 +777,7 @@ pub fn register( .with_proxy(integration.clone()) .with_attribute_rewriter(integration.clone()) .with_head_injector(integration) - .with_deferred_js() + .without_js() .build(), )) } @@ -431,6 +791,8 @@ impl IntegrationProxy for PrebidIntegration { fn routes(&self) -> Vec { let mut routes = vec![]; + routes.push(self.get("/bundle.js")); + // Register routes for script removal patterns // Patterns can be exact paths (e.g., "/prebid.min.js") or use matchit wildcards // (e.g., "/static/prebid/{*rest}") @@ -446,14 +808,17 @@ impl IntegrationProxy for PrebidIntegration { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + settings: &Settings, + services: &RuntimeServices, req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); let method = req.method().clone(); match method { + Method::GET if self.is_managed_external() && path == PREBID_BUNDLE_ROUTE => { + self.handle_external_bundle(settings, services, req).await + } // Serve empty JS for matching script patterns Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), _ => http::Response::builder() @@ -522,9 +887,13 @@ impl IntegrationHeadInjector for PrebidIntegration { }) .replace("window.pbjs=window.pbjs||{{}};window.pbjs.que=window.pbjs.que||[];window.pbjs.cmd=window.pbjs.cmd||[];window.__tsjs_prebid={config_json};"# - )] + )]; + + inserts.push(self.external_bundle_script_tag()); + + inserts } } @@ -1721,14 +2090,15 @@ mod tests { }; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; - use crate::test_support::tests::crate_test_settings_str; + use crate::test_support::tests::create_test_settings; + use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD; use http::Method; use serde_json::json; use std::collections::HashMap; use std::io::Cursor; fn make_settings() -> Settings { - Settings::from_toml(&crate_test_settings_str()).expect("should parse settings") + create_test_settings() } fn base_config() -> PrebidIntegrationConfig { @@ -1742,6 +2112,11 @@ mod tests { test_mode: false, debug_query_params: None, script_patterns: default_script_patterns(), + external_bundle_url: Some( + "https://assets.example/prebid/trusted-prebid.js".to_string(), + ), + external_bundle_sha256: None, + external_bundle_sri: None, client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::default(), bid_param_overrides: HashMap::default(), @@ -1802,6 +2177,40 @@ mod tests { ); } + fn test_sri(algorithm: &str, digest: &[u8]) -> String { + format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) + } + + fn test_request(url: impl AsRef) -> http::Request { + http::Request::builder() + .method(http::Method::GET) + .uri(url.as_ref()) + .body(EdgeBody::empty()) + .expect("should build request") + } + + fn header_value_str(response: &http::Response, name: &str) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok().map(std::string::ToString::to_string)) + } + + fn response_header_is_present(response: &http::Response, name: &str) -> bool { + response.headers().contains_key(name) + } + + fn response_body_string(response: http::Response) -> String { + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("should parse response body as utf-8") + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), @@ -1994,6 +2403,7 @@ passphrase = "test-secret-key-32-bytes-minimum" &json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "script_patterns": [], @@ -2044,6 +2454,7 @@ passphrase = "test-secret-key-32-bytes-minimum" &json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "script_patterns": ["/prebid.js", "/prebid.min.js"], @@ -2078,8 +2489,12 @@ passphrase = "test-secret-key-32-bytes-minimum" "Prebid preload should be removed when auto-config is enabled" ); assert!( - processed.contains("tsjs-prebid.min.js"), - "Deferred prebid bundle should be injected" + processed.contains(PREBID_BUNDLE_ROUTE), + "External prebid bundle route should be injected" + ); + assert!( + !processed.contains("tsjs-prebid.min.js"), + "Embedded deferred prebid bundle should not be injected" ); } @@ -2126,6 +2541,204 @@ server_url = "https://prebid.example" .contains(&"/prebid.min.js".to_string())); } + #[test] + fn external_bundle_config_parses_with_optional_hash_metadata() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +"#, + ); + + assert_eq!( + config.external_bundle_url.as_deref(), + Some("https://assets.example/prebid/trusted-prebid.js"), + "should preserve configured external bundle URL" + ); + assert!( + config.external_bundle_sha256.is_none(), + "SHA-256 should be optional" + ); + } + + #[test] + fn external_bundle_config_rejects_malformed_hash_metadata() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sha256 = "not-a-sha" +"#, + ) + .expect_err("should reject malformed SHA-256"); + + assert!( + err.to_string().contains("external_bundle_sha256"), + "error should mention malformed SHA-256: {err:?}" + ); + } + + #[test] + fn external_bundle_config_rejects_non_https_bundle_url() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "http://assets.example/prebid/trusted-prebid.js" +"#, + ) + .expect_err("should reject non-HTTPS external bundle URL"); + + assert!( + err.to_string().contains("external_bundle_url"), + "error should mention external bundle URL: {err:?}" + ); + } + + #[test] + fn external_bundle_config_rejects_invalid_sri_base64() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sri = "sha384-not-valid!!!" +"#, + ) + .expect_err("should reject invalid SRI base64"); + + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention external bundle SRI: {err:?}" + ); + } + + #[test] + fn external_bundle_config_rejects_sri_with_wrong_digest_length() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sri = "sha384-AAAA" +"#, + ) + .expect_err("should reject SRI with wrong digest length"); + + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention external bundle SRI: {err:?}" + ); + } + + #[test] + fn external_bundle_registration_allows_sha256_without_sri() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64) + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings) + .expect("should create registry with valid SHA-256 and no SRI"); + + assert!( + registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), + "should register external bundle route" + ); + } + + #[test] + fn external_bundle_registration_allows_sha256_with_valid_sha384_sri() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64), + "external_bundle_sri": test_sri("sha384", &[0; 48]) + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings) + .expect("should create registry with valid SHA-256 and SHA-384 SRI"); + + assert!( + registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), + "should register external bundle route" + ); + } + + #[test] + fn external_bundle_registration_requires_bundle_url() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction" + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject missing URL"), + Err(err) => err, + }; + assert!( + err.to_string().contains("external_bundle_url"), + "error should mention missing external bundle URL: {err:?}" + ); + } + + #[test] + fn external_bundle_registration_uses_proxy_allowed_domains() { + let mut settings = make_settings(); + settings.proxy.allowed_domains = vec!["allowed.example".to_string()]; + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "external_bundle_url": "https://blocked.example/prebid/trusted-prebid.js" + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject bundle host outside proxy.allowed_domains"), + Err(err) => err, + }; + assert!( + err.to_string().contains("proxy.allowed_domains"), + "error should mention proxy.allowed_domains: {err:?}" + ); + } + #[test] fn script_handler_returns_empty_js() { let integration = PrebidIntegration::new(base_config()); @@ -2161,6 +2774,362 @@ server_url = "https://prebid.example" assert!(body.contains("// Script overridden by Trusted Server")); } + #[test] + fn external_bundle_request_cache_mode_validates_version_query() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + let integration = PrebidIntegration::new(config); + + let versioned_req = test_request(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={sha256}" + )); + let missing_version_req = test_request(format!("https://pub.example{PREBID_BUNDLE_ROUTE}")); + let mismatched_req = test_request(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={}", + "b".repeat(64) + )); + + assert_eq!( + integration + .external_bundle_request_cache_mode(&versioned_req) + .expect("should parse versioned request"), + Some(ExternalBundleCacheMode::Immutable), + "matching v query should use immutable cache mode" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&missing_version_req) + .expect("should parse unversioned request"), + Some(ExternalBundleCacheMode::Revalidate), + "missing v query should use revalidation cache mode" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&mismatched_req) + .expect("should parse mismatched request"), + None, + "mismatched v query should 404" + ); + } + + #[test] + fn external_bundle_request_cache_mode_rejects_version_when_hash_is_absent() { + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + + let versioned_req = test_request(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={}", + "a".repeat(64) + )); + let unversioned_req = test_request(format!("https://pub.example{PREBID_BUNDLE_ROUTE}")); + + assert_eq!( + integration + .external_bundle_request_cache_mode(&versioned_req) + .expect("should parse versioned request"), + None, + "v query should 404 when SHA-256 is omitted" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&unversioned_req) + .expect("should parse unversioned request"), + Some(ExternalBundleCacheMode::Revalidate), + "unversioned request should be served with revalidation cache mode" + ); + } + + #[test] + fn external_bundle_headers_use_cache_policy_for_mode() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + + let mut immutable = http::Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build response"); + integration + .apply_external_bundle_headers(&mut immutable, ExternalBundleCacheMode::Immutable); + assert_eq!( + header_value_str(&immutable, "content-type"), + Some(PREBID_BUNDLE_CONTENT_TYPE.to_string()), + "should normalize JS content type" + ); + assert_eq!( + header_value_str(&immutable, "cache-control"), + Some(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL.to_string()), + "versioned responses should be immutable" + ); + assert_eq!( + header_value_str(&immutable, "etag"), + Some(format!("\"sha256:{sha256}\"")), + "should emit configured hash ETag" + ); + + let mut revalidate = http::Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build response"); + integration + .apply_external_bundle_headers(&mut revalidate, ExternalBundleCacheMode::Revalidate); + assert_eq!( + header_value_str(&revalidate, "cache-control"), + Some(PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL.to_string()), + "unversioned responses should use short-lived revalidation" + ); + } + + #[test] + fn external_bundle_response_sanitization_uses_header_whitelist_for_ok_response() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + + let mut upstream = http::Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::from("console.log('ok');")) + .expect("should build upstream response"); + upstream + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html")); + upstream.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, max-age=0"), + ); + upstream.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_static("bad=1; Path=/"), + ); + upstream + .headers_mut() + .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); + upstream + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from_static("16")); + upstream.headers_mut().insert( + header::HeaderName::from_static("x-upstream"), + HeaderValue::from_static("leak"), + ); + + let sanitized = integration + .sanitize_external_bundle_response(upstream, ExternalBundleCacheMode::Immutable); + + assert_eq!( + header_value_str(&sanitized, "content-type"), + Some(PREBID_BUNDLE_CONTENT_TYPE.to_string()), + "should normalize JS content type" + ); + assert_eq!( + header_value_str(&sanitized, "cache-control"), + Some(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL.to_string()), + "should apply trusted cache policy" + ); + assert_eq!( + header_value_str(&sanitized, "etag"), + Some(format!("\"sha256:{sha256}\"")), + "should emit trusted ETag" + ); + assert_eq!( + header_value_str(&sanitized, "content-encoding"), + Some("gzip".to_string()), + "should preserve body encoding metadata" + ); + assert!( + !response_header_is_present(&sanitized, "content-length"), + "should strip upstream content length so the platform can derive it from the body" + ); + assert!( + !response_header_is_present(&sanitized, "set-cookie"), + "should strip upstream Set-Cookie" + ); + assert!( + !response_header_is_present(&sanitized, "x-upstream"), + "should strip arbitrary upstream headers" + ); + assert_eq!( + response_body_string(sanitized), + "console.log('ok');", + "should preserve body bytes" + ); + } + + #[test] + fn external_bundle_response_sanitization_strips_headers_for_error_response() { + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + + let mut upstream = http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("missing")) + .expect("should build upstream response"); + upstream + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html")); + upstream.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000"), + ); + upstream.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_static("bad=1; Path=/"), + ); + upstream.headers_mut().insert( + header::HeaderName::from_static("x-upstream"), + HeaderValue::from_static("leak"), + ); + + let sanitized = integration + .sanitize_external_bundle_response(upstream, ExternalBundleCacheMode::Revalidate); + + assert_eq!( + sanitized.status(), + StatusCode::NOT_FOUND, + "should preserve upstream status" + ); + assert_eq!( + header_value_str(&sanitized, "cache-control"), + Some(PREBID_BUNDLE_ERROR_CACHE_CONTROL.to_string()), + "should prevent caching upstream error responses" + ); + assert!( + !response_header_is_present(&sanitized, "content-type"), + "should strip upstream content type on error responses" + ); + assert!( + !response_header_is_present(&sanitized, "set-cookie"), + "should strip upstream Set-Cookie on error responses" + ); + assert!( + !response_header_is_present(&sanitized, "x-upstream"), + "should strip arbitrary upstream headers on error responses" + ); + } + + #[test] + fn external_bundle_startup_validation_requires_proxy_allowed_domains() { + let mut settings = make_settings(); + settings.proxy.allowed_domains.clear(); + + let err = validate_config_for_startup(&settings) + .expect_err("should reject external bundle without proxy allowlist"); + + assert!( + err.to_string().contains("proxy.allowed_domains"), + "error should mention proxy.allowed_domains: {err:?}" + ); + } + + #[test] + fn external_bundle_handler_fetches_and_sanitizes_with_platform_client() { + futures::executor::block_on(async { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_sha256 = Some(sha256.clone()); + let integration = PrebidIntegration::new(config); + let mut settings = make_settings(); + settings.proxy.allowed_domains = vec!["assets.example".to_string()]; + + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"console.log('bundle');".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "text/html"), + (header::CACHE_CONTROL.as_str(), "private, max-age=0"), + (header::SET_COOKIE.as_str(), "bad=1; Path=/"), + ("x-upstream", "leak"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = http::Request::builder() + .method(Method::GET) + .uri(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={sha256}" + )) + .header(header::COOKIE, "ts-ec=should-not-forward") + .header(header::ACCEPT, "*/*") + .body(EdgeBody::empty()) + .expect("should build external bundle request"); + + let response = integration + .handle_external_bundle(&settings, &services, req) + .await + .expect("should proxy external bundle"); + + assert_eq!(response.status(), StatusCode::OK, "should preserve status"); + assert_eq!( + header_value_str(&response, header::CONTENT_TYPE.as_str()), + Some(PREBID_BUNDLE_CONTENT_TYPE.to_string()), + "should normalize JS content type" + ); + assert_eq!( + header_value_str(&response, header::CACHE_CONTROL.as_str()), + Some(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL.to_string()), + "versioned bundle response should be immutable" + ); + assert_eq!( + header_value_str(&response, header::ETAG.as_str()), + Some(format!("\"sha256:{sha256}\"")), + "should emit configured hash ETag" + ); + assert!( + !response_header_is_present(&response, header::SET_COOKIE.as_str()), + "should strip upstream Set-Cookie" + ); + assert!( + !response_header_is_present(&response, "x-upstream"), + "should strip arbitrary upstream headers" + ); + assert_eq!( + response_body_string(response), + "console.log('bundle');", + "should preserve bundle bytes" + ); + + assert_eq!( + stub.recorded_request_uris(), + vec!["https://assets.example/prebid/trusted-prebid.js".to_string()], + "should fetch the configured external bundle URL without adding EC query params" + ); + let recorded_headers = stub.recorded_request_headers(); + assert_eq!( + recorded_headers.len(), + 1, + "should make one upstream request" + ); + assert!( + !recorded_headers[0] + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(header::COOKIE.as_str())), + "external bundle fetch should not forward client cookies" + ); + assert!( + !recorded_headers[0] + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(header::ACCEPT.as_str())), + "external bundle fetch should not forward client headers" + ); + }); + } + #[test] fn routes_include_script_patterns() { let integration = PrebidIntegration::new(base_config()); @@ -2182,6 +3151,12 @@ server_url = "https://prebid.example" has_prebid_min_js_route, "should register /prebid.min.js route" ); + assert!( + routes + .iter() + .any(|r| r.path == PREBID_BUNDLE_ROUTE && r.method == Method::GET), + "should register the bundle route" + ); } #[test] @@ -2196,7 +3171,7 @@ server_url = "https://prebid.example" }; let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should produce exactly one head insert"); + assert_eq!(inserts.len(), 2, "should produce config and bundle inserts"); let script = &inserts[0]; assert!( @@ -2247,6 +3222,72 @@ server_url = "https://prebid.example" ); } + #[test] + fn head_injector_emits_external_bundle_script_with_hash_and_integrity() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!(inserts.len(), 2, "should emit config and bundle scripts"); + assert!( + inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}?v={sha256}\"")), + "bundle script should use content-addressed first-party URL: {}", + inserts[1] + ); + assert!( + inserts[1].contains("integrity=\"sha384-"), + "bundle script should include configured SRI: {}", + inserts[1] + ); + assert!( + !inserts[1].contains("crossorigin"), + "same-origin bundle script should not include crossorigin: {}", + inserts[1] + ); + } + + #[test] + fn head_injector_emits_external_bundle_script_without_hash_query_when_unhashed() { + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!(inserts.len(), 2, "should emit config and bundle scripts"); + assert!( + inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}\"")), + "bundle script should use first-party route without hash query: {}", + inserts[1] + ); + assert!( + !inserts[1].contains("?v="), + "unhashed bundle script should not include version query: {}", + inserts[1] + ); + } + #[test] fn head_injector_escapes_closing_script_tags_in_values() { let mut config = base_config(); @@ -3414,8 +4455,17 @@ server_url = "https://prebid.example" let routes = integration.routes(); - // Should have 0 routes when no script patterns configured - assert_eq!(routes.len(), 0); + assert_eq!( + routes.len(), + 1, + "should keep bundle route when no script patterns configured" + ); + assert!( + routes + .iter() + .any(|route| route.path == PREBID_BUNDLE_ROUTE && route.method == Method::GET), + "should register the bundle route" + ); } #[test] diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 103657e3e..0814f8232 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -581,6 +581,7 @@ pub trait IntegrationHeadInjector: Send + Sync { pub struct IntegrationRegistration { pub integration_id: &'static str, pub js_deferred: bool, + pub js_disabled: bool, pub proxies: Vec>, pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, @@ -606,6 +607,7 @@ impl IntegrationRegistrationBuilder { registration: IntegrationRegistration { integration_id, js_deferred: false, + js_disabled: false, proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), @@ -666,6 +668,14 @@ impl IntegrationRegistrationBuilder { self } + /// Disable TSJS module inclusion for an integration that is handled by other assets. + #[must_use] + pub fn without_js(mut self) -> Self { + self.registration.js_disabled = true; + self.registration.js_deferred = false; + self + } + #[must_use] pub fn build(self) -> IntegrationRegistration { self.registration @@ -688,6 +698,7 @@ struct IntegrationRegistryInner { routes: Vec<(IntegrationEndpoint, &'static str)>, enabled_integration_ids: Vec<&'static str>, deferred_js_ids: Vec<&'static str>, + disabled_js_ids: Vec<&'static str>, html_rewriters: Vec>, script_rewriters: Vec>, html_post_processors: Vec>, @@ -707,12 +718,13 @@ impl Default for IntegrationRegistryInner { options_router: Router::new(), routes: Vec::new(), enabled_integration_ids: Vec::new(), + deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), - deferred_js_ids: Vec::new(), } } } @@ -776,7 +788,11 @@ impl IntegrationRegistry { let mut inner = IntegrationRegistryInner::default(); for builder in crate::integrations::builders() { - if let Some(registration) = builder(settings)? { + if let Some(registration) = (builder.build)(settings)? { + debug_assert_eq!( + registration.integration_id, builder.id, + "integration builder ID should match registration ID" + ); inner .enabled_integration_ids .push(registration.integration_id); @@ -838,7 +854,9 @@ impl IntegrationRegistry { .extend(registration.html_post_processors); inner.head_injectors.extend(registration.head_injectors); inner.request_filters.extend(registration.request_filters); - if registration.js_deferred { + if registration.js_disabled { + inner.disabled_js_ids.push(registration.integration_id); + } else if registration.js_deferred { inner.deferred_js_ids.push(registration.integration_id); } } @@ -1099,7 +1117,10 @@ impl IntegrationRegistry { let mut ids: Vec<&'static str> = JS_ALWAYS.to_vec(); for id in &self.inner.enabled_integration_ids { - if trusted_server_js::module_bundle(id).is_some() && !ids.contains(id) { + if trusted_server_js::module_bundle(id).is_some() + && !self.inner.disabled_js_ids.contains(id) + && !ids.contains(id) + { ids.push(*id); } } @@ -1154,6 +1175,7 @@ impl IntegrationRegistry { head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1182,6 +1204,7 @@ impl IntegrationRegistry { head_injectors, request_filters: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1206,6 +1229,7 @@ impl IntegrationRegistry { head_injectors: Vec::new(), request_filters, deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1270,6 +1294,7 @@ impl IntegrationRegistry { head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1916,7 +1941,7 @@ mod tests { } #[test] - fn js_module_ids_immediate_excludes_prebid_and_includes_core_js_only_modules() { + fn js_module_ids_exclude_prebid_and_include_core_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1926,6 +1951,7 @@ mod tests { &serde_json::json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "debug": false @@ -1941,8 +1967,8 @@ mod tests { let deferred = registry.js_module_ids_deferred(); assert!( - all.contains(&"prebid"), - "should include prebid in full list" + !all.contains(&"prebid"), + "should not include prebid in embedded TSJS module IDs" ); assert!( immediate.contains(&"creative"), @@ -1957,8 +1983,8 @@ mod tests { "should not include prebid in immediate IDs" ); assert!( - deferred.contains(&"prebid"), - "should include prebid in deferred IDs" + !deferred.contains(&"prebid"), + "should not include prebid in deferred IDs" ); } @@ -2027,7 +2053,8 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction" + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); @@ -2041,6 +2068,41 @@ mod tests { ); } + #[test] + fn js_module_ids_exclude_prebid_when_external_bundle_is_configured() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config( + "prebid", + &serde_json::json!({ + "enabled": true, + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + + assert!( + !registry.js_module_ids().contains(&"prebid"), + "external bundle mode should not include prebid in embedded TSJS modules" + ); + assert!( + !registry.js_module_ids_immediate().contains(&"prebid"), + "external bundle mode should not include prebid in immediate TSJS modules" + ); + assert!( + !registry.js_module_ids_deferred().contains(&"prebid"), + "external bundle mode should not include prebid in deferred TSJS modules" + ); + assert!( + registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), + "external bundle mode should register the first-party bundle route" + ); + } + #[test] fn js_module_ids_split_is_exhaustive() { let settings = crate::test_support::tests::create_test_settings(); @@ -2052,6 +2114,7 @@ mod tests { &serde_json::json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "debug": false diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 9bf58e630..9d5ab8af6 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -313,16 +313,18 @@ pub struct ProxyRequestConfig<'a> { pub stream_passthrough: bool, /// Domains allowed for the initial request and any redirects. /// - /// **Open mode** (`&[]`): every host is permitted. Integration proxies pass `&[]` - /// because their target URLs originate from operator-controlled configuration + /// **Open mode** (`&[]`): every host is permitted. Most integration proxies pass + /// `&[]` because their target URLs originate from operator-controlled configuration /// (e.g. `trusted-server.toml` integration settings) and are therefore trusted at /// operator setup time rather than at request time. /// /// **Restricted mode** (non-empty slice): only hosts matching a listed pattern are - /// permitted. Currently only [`handle_first_party_proxy`] passes - /// `&settings.proxy.allowed_domains` because it follows redirect chains that may - /// originate from untrusted creative-supplied URLs. + /// permitted. First-party creative proxying and managed static-asset proxies pass + /// `&settings.proxy.allowed_domains` because they follow redirect chains that must + /// stay constrained to operator-approved hosts. pub allowed_domains: &'a [String], + /// Require the initial target and every followed redirect hop to use HTTPS. + pub require_https: bool, } impl<'a> ProxyRequestConfig<'a> { @@ -338,6 +340,7 @@ impl<'a> ProxyRequestConfig<'a> { copy_request_headers: true, stream_passthrough: false, allowed_domains: &[], + require_https: false, } } @@ -368,6 +371,27 @@ impl<'a> ProxyRequestConfig<'a> { self.stream_passthrough = true; self } + + /// Disable EC ID query-param forwarding to the target URL. + #[must_use] + pub fn without_ec_id(mut self) -> Self { + self.forward_ec_id = false; + self + } + + /// Enforce a domain allowlist on the target URL and followed redirects. + #[must_use] + pub fn with_allowed_domains(mut self, allowed_domains: &'a [String]) -> Self { + self.allowed_domains = allowed_domains; + self + } + + /// Require HTTPS for the target URL and followed redirects. + #[must_use] + pub fn with_https_only(mut self) -> Self { + self.require_https = true; + self + } } /// Encodings we support decompressing in `finalize_proxied_response`. @@ -646,6 +670,7 @@ struct ProxyRedirectPolicy<'a> { follow_redirects: bool, stream_passthrough: bool, allowed_domains: &'a [String], + require_https: bool, } /// Proxy a request to a clear target URL while reusing creative rewrite logic. @@ -673,6 +698,7 @@ pub async fn proxy_request( copy_request_headers, stream_passthrough, allowed_domains, + require_https, } = config; let mut target_url_parsed = url::Url::parse(target_url).map_err(|_| { @@ -699,6 +725,7 @@ pub async fn proxy_request( follow_redirects, stream_passthrough, allowed_domains, + require_https, }, ) .await @@ -1186,7 +1213,7 @@ fn redirect_is_permitted>(allowed_domains: &[S], host: &str) -> bo /// /// Comparison is case-insensitive. The wildcard check requires a dot boundary, /// so `"*.example.com"` does **not** match `"evil-example.com"`. -fn is_host_allowed(host: &str, pattern: &str) -> bool { +pub(crate) fn is_host_allowed(host: &str, pattern: &str) -> bool { let host = host.to_ascii_lowercase(); let pattern = pattern.to_ascii_lowercase(); @@ -1226,6 +1253,12 @@ async fn proxy_with_redirects( message: "unsupported scheme".to_string(), })); } + if redirect_policy.require_https && scheme != "https" { + log::warn!("request to `{}` blocked: HTTPS is required", current_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS proxy target blocked".to_string(), + })); + } let host = parsed_url.host_str().unwrap_or(""); if host.is_empty() { @@ -1369,6 +1402,12 @@ async fn proxy_with_redirects( })?; let next_scheme = next_url.scheme().to_ascii_lowercase(); + if redirect_policy.require_https && next_scheme != "https" { + log::warn!("redirect to `{}` blocked: HTTPS is required", next_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS redirect blocked".to_string(), + })); + } if next_scheme != "http" && next_scheme != "https" { return finalize_response( settings, @@ -1452,6 +1491,7 @@ pub async fn handle_first_party_proxy( copy_request_headers: true, stream_passthrough: false, allowed_domains: &settings.proxy.allowed_domains, + require_https: false, }, services, ) @@ -3106,6 +3146,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3146,6 +3187,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3191,6 +3233,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3239,6 +3282,7 @@ mod tests { copy_request_headers: true, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3303,6 +3347,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -4528,14 +4573,9 @@ mod tests { #[test] fn redirect_empty_allowlist_permits_any() { - // The guard at proxy_with_redirects checks `!allowed_domains.is_empty()` - // before calling is_host_allowed, so no host is ever blocked when the - // list is empty. Verify the combined condition is false for any host. let allowed: [String; 0] = []; - let would_block = - !allowed.is_empty() && !allowed.iter().any(|p| is_host_allowed("evil.com", p)); assert!( - !would_block, + redirect_is_permitted(&allowed, "evil.com"), "empty allowlist should not block any redirect host" ); } @@ -4648,6 +4688,39 @@ mod tests { // below verify that proxy_request threads config.allowed_domains through // the initial target check and redirect hops. + #[test] + fn proxy_request_blocks_non_https_target_when_https_only() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let services = crate::platform::test_support::build_services_with_http_client( + Arc::new(StreamingResponseHttpClient) as Arc, + ); + let req = build_http_request( + Method::GET, + "https://edge.example/integrations/prebid/bundle.js", + ); + let config = ProxyRequestConfig::new("http://assets.example/prebid/trusted-prebid.js") + .without_ec_id() + .without_forward_headers() + .with_streaming() + .with_https_only(); + + let err = proxy_request(&settings, req, config, &services) + .await + .expect_err("should block non-HTTPS target before proxying"); + + assert_eq!( + err.current_context().status_code(), + StatusCode::FORBIDDEN, + "HTTPS-only proxy requests should reject http targets" + ); + assert!( + matches!(err.current_context(), TrustedServerError::Forbidden { .. }), + "should return a forbidden error" + ); + }); + } + #[test] fn proxy_initial_target_blocked_by_allowlist() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1e41044e8..eb175af8c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -171,7 +171,7 @@ pub fn handle_tsjs_dynamic( Ok(not_found_response()) } -/// Extract a module ID from a deferred-module filename like `tsjs-prebid.min.js`. +/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`. /// /// Returns `Some(&'static str)` if the filename matches a known JS module ID, /// `None` otherwise. The caller must additionally verify that the module is @@ -1563,14 +1563,14 @@ mod tests { #[test] fn parse_deferred_module_filename_extracts_known_id() { assert_eq!( - parse_deferred_module_filename("tsjs-prebid.min.js"), - Some("prebid"), - "should extract prebid from minified filename" + parse_deferred_module_filename("tsjs-sourcepoint.min.js"), + Some("sourcepoint"), + "should extract sourcepoint from minified filename" ); assert_eq!( - parse_deferred_module_filename("tsjs-prebid.js"), - Some("prebid"), - "should extract prebid from unminified filename" + parse_deferred_module_filename("tsjs-sourcepoint.js"), + Some("sourcepoint"), + "should extract sourcepoint from unminified filename" ); } @@ -1592,15 +1592,14 @@ mod tests { "should reject without tsjs- prefix" ); assert_eq!( - parse_deferred_module_filename("tsjs-prebid.txt"), + parse_deferred_module_filename("tsjs-sourcepoint.txt"), None, "should reject non-js extension" ); } #[test] - fn tsjs_dynamic_serves_deferred_prebid_when_enabled() { - // Default test settings include prebid enabled + fn tsjs_dynamic_does_not_serve_embedded_prebid() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -1612,8 +1611,8 @@ mod tests { let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( response.status(), - StatusCode::OK, - "should serve deferred prebid module when enabled" + StatusCode::NOT_FOUND, + "should not serve embedded prebid module" ); } @@ -1626,7 +1625,8 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction" + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 63e2fa296..5f094c0d2 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -23,7 +23,8 @@ pub mod tests { [integrations.prebid] enabled = true - server_url = "https://test-prebid.com/openrtb2/auction" + server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [integrations.nextjs] enabled = false @@ -46,7 +47,9 @@ pub mod tests { /// Panics if the embedded TOML configuration is invalid. pub fn create_test_settings() -> Settings { let toml_str = crate_test_settings_str(); - Settings::from_toml(&toml_str).expect("Invalid config") + let mut settings = Settings::from_toml(&toml_str).expect("Invalid config"); + settings.proxy.allowed_domains = vec!["*.example".to_string(), "*.example.com".to_string()]; + settings } /// A valid EC ID in `{64-hex}.{6-alnum}` format for use in tests. diff --git a/crates/trusted-server-core/src/tester_cookie.rs b/crates/trusted-server-core/src/tester_cookie.rs index 4cb3eeaf6..16371a757 100644 --- a/crates/trusted-server-core/src/tester_cookie.rs +++ b/crates/trusted-server-core/src/tester_cookie.rs @@ -2,7 +2,9 @@ //! //! The tester routes are intentionally disabled unless configured. When enabled, //! they set or clear a first-party `ts-tester` cookie scoped to the configured -//! publisher cookie domain. +//! publisher cookie domain. They are self-service routes rather than admin +//! routes; the cookie only affects tester routing and must not gate sensitive +//! behavior. use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 07c0c97e3..45aee02eb 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -113,10 +113,10 @@ mod tests { #[test] fn tsjs_script_src_hash_changes_with_module_set() { let creative_src = tsjs_script_src(&["creative"]); - let creative_prebid_src = tsjs_script_src(&["creative", "prebid"]); + let creative_datadome_src = tsjs_script_src(&["creative", "datadome"]); assert_ne!( - creative_src, creative_prebid_src, + creative_src, creative_datadome_src, "should include requested modules in cache-busting hash" ); } @@ -124,8 +124,8 @@ mod tests { #[test] fn tsjs_script_src_hash_depends_on_module_order() { assert_ne!( - tsjs_script_src(&["creative", "prebid"]), - tsjs_script_src(&["prebid", "creative"]), + tsjs_script_src(&["creative", "datadome"]), + tsjs_script_src(&["datadome", "creative"]), "should include module order in cache-busting hash" ); } @@ -133,8 +133,8 @@ mod tests { #[test] fn tsjs_script_src_deduplicates_core_module() { assert_eq!( - tsjs_script_src(&["core", "prebid"]), - tsjs_script_src(&["prebid"]), + tsjs_script_src(&["core", "datadome"]), + tsjs_script_src(&["datadome"]), "should not hash core twice when requested explicitly" ); } @@ -182,17 +182,22 @@ mod tests { #[test] fn tsjs_deferred_script_src_formats_known_module_url_with_hash() { - let src = tsjs_deferred_script_src("prebid"); + let src = tsjs_deferred_script_src("creative"); assert!( - src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="), + src.starts_with("/static/tsjs=tsjs-creative.min.js?v="), "should use per-module static bundle path" ); assert_sha256_hex_hash(hash_query_value(&src)); } #[test] - fn tsjs_deferred_script_src_uses_empty_hash_for_unknown_module() { + fn tsjs_deferred_script_src_uses_empty_hash_for_external_or_unknown_module() { + assert_eq!( + tsjs_deferred_script_src("prebid"), + "/static/tsjs=tsjs-prebid.min.js?v=", + "prebid now ships as an external bundle and has no local hash" + ); assert_eq!( tsjs_deferred_script_src("unknown-module"), "/static/tsjs=tsjs-unknown-module.min.js?v=", diff --git a/crates/trusted-server-js/lib/.gitignore b/crates/trusted-server-js/lib/.gitignore index f1283e533..1fd14250a 100644 --- a/crates/trusted-server-js/lib/.gitignore +++ b/crates/trusted-server-js/lib/.gitignore @@ -1,2 +1,3 @@ # Auto-generated files src/generated-modules.ts +src/integrations/prebid/.external-generated-* diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 61892df60..ce465a72d 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -8,242 +8,24 @@ * tsjs-core.js — core API (always included) * tsjs-.js — one per discovered integration * - * Environment variables: - * TSJS_PREBID_ADAPTERS — Comma-separated list of Prebid.js bid adapter - * names to include in the bundle (e.g. "rubicon,appnexus,openx"). - * Each name must have a corresponding {name}BidAdapter.js module in - * the prebid.js package. Default: no adapters. - * - * TSJS_PREBID_USER_ID_MODULES — Ignored for production builds. User ID - * modules are selected from src/integrations/prebid/user_id_modules.json - * so attested bundles are deterministic. For local experiments only, use - * TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE. + * Prebid is intentionally excluded from this embedded build. Use + * build-prebid-external.mjs to generate publisher-specific Prebid bundles + * outside the Cargo build. */ -import crypto from 'node:crypto'; import fs from 'node:fs'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); const srcDir = path.resolve(__dirname, 'src'); const distDir = path.resolve(__dirname, '..', 'dist'); const integrationsDir = path.join(srcDir, 'integrations'); -// --------------------------------------------------------------------------- -// Prebid adapter generation -// --------------------------------------------------------------------------- - -const DEFAULT_PREBID_ADAPTERS = ''; -const DEFAULT_PREBID_ADAPTERS_DESCRIPTION = DEFAULT_PREBID_ADAPTERS || 'no adapters'; -const ADAPTERS_FILE = path.join(integrationsDir, 'prebid', '_adapters.generated.ts'); -const USER_IDS_FILE = path.join(integrationsDir, 'prebid', '_user_ids.generated.ts'); - -const USER_ID_REGISTRY_FILE = path.join(integrationsDir, 'prebid', 'user_id_modules.json'); -const USER_IDS_MANIFEST_FILE = path.join(distDir, 'prebid-user-id-modules.json'); -const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; -const PREBID_PACKAGE_DIR = path.join(__dirname, 'node_modules', 'prebid.js'); -const PREBID_LIVE_INTENT_STANDARD = path.join( - PREBID_PACKAGE_DIR, - 'dist', - 'src', - 'libraries', - 'liveIntentId', - 'idSystem.js' -); -const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); -const LIVE_INTENT_SHIM = path.join( - integrationsDir, - 'prebid', - 'prebid_modules', - 'liveIntentIdSystem.ts' -); - -/** - * Generate `_adapters.generated.ts` with import statements for each adapter - * listed in the TSJS_PREBID_ADAPTERS environment variable. - * - * Invalid adapter names (those without a matching module in prebid.js) are - * logged and skipped. - */ -function generatePrebidAdapters() { - const raw = process.env.TSJS_PREBID_ADAPTERS ?? DEFAULT_PREBID_ADAPTERS; - const names = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - const modulesDir = path.join(__dirname, 'node_modules', 'prebid.js', 'modules'); - - // Validate each adapter and build import lines - const imports = []; - for (const name of names) { - const moduleFile = `${name}BidAdapter.js`; - const modulePath = path.join(modulesDir, moduleFile); - if (!fs.existsSync(modulePath)) { - console.error( - `[build-all] WARNING: Prebid adapter "${name}" not found (expected ${moduleFile}), skipping` - ); - continue; - } - imports.push(`import 'prebid.js/modules/${moduleFile}';`); - } - - if (imports.length === 0) { - if (names.length === 0) { - console.log( - '[build-all] No Prebid adapters configured; bundle will have no client-side adapters' - ); - } else { - console.error( - '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters' - ); - } - } - - const header = [ - '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', - '//', - '// Controls which Prebid.js bid adapters are included in the bundle.', - '// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list', - '// of adapter names (e.g. "rubicon,appnexus,openx") before building.', - `// Default: ${DEFAULT_PREBID_ADAPTERS_DESCRIPTION}`, - ].join('\n'); - const content = imports.length === 0 ? `${header}\n` : `${header}\n\n${imports.join('\n')}\n`; - - fs.writeFileSync(ADAPTERS_FILE, content); - - const adapterNames = names.filter((name) => - fs.existsSync(path.join(modulesDir, `${name}BidAdapter.js`)) - ); - console.log('[build-all] Prebid adapters:', adapterNames); -} - -function readUserIdRegistry() { - return JSON.parse(fs.readFileSync(USER_ID_REGISTRY_FILE, 'utf8')); -} - -function requireExistingFile(filePath, description) { - if (!fs.existsSync(filePath)) { - throw new Error(`[build-all] Missing ${description}: ${filePath}`); - } -} - -function prebidPackageVersion() { - const packageJsonPath = path.join(PREBID_PACKAGE_DIR, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - return packageJson.version; -} - -function sourceToModuleMap(entries) { - const map = {}; - for (const entry of entries) { - for (const source of entry.eidSources ?? []) { - map[source] = entry.moduleName; - } - } - return map; -} - -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'); - - if (entry.moduleName === 'liveIntentIdSystem') { - return; - } - - try { - require.resolve(entry.importPath, { paths: [__dirname] }); - } catch (error) { - throw new Error( - `[build-all] Required Prebid user ID module "${entry.moduleName}" could not be resolved from ${entry.importPath}: ${error.message}` - ); - } -} - -/** - * Generate `_user_ids.generated.ts` with deterministic User ID imports. - * - * Production builds intentionally ignore TSJS_PREBID_USER_ID_MODULES so the - * attested JS artifact does not vary per publisher. A dev-only override exists - * for local experiments and should not be used for trusted deployments. - */ -function generatePrebidUserIdModules() { - const registry = readUserIdRegistry(); - const entriesByModule = new Map(registry.modules.map((entry) => [entry.moduleName, entry])); - const override = process.env.TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE; - const moduleNames = override - ? override - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : registry.defaultPreset; - - if (process.env.TSJS_PREBID_USER_ID_MODULES && !override) { - console.warn( - '[build-all] TSJS_PREBID_USER_ID_MODULES is ignored for deterministic attested builds. ' + - 'Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments.' - ); - } - - if (override) { - console.warn( - '[build-all] WARNING: using TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE. ' + - 'This changes the Prebid bundle and breaks production attestation assumptions.' - ); - } - - const selectedEntries = moduleNames.map((moduleName) => { - const entry = entriesByModule.get(moduleName); - if (!entry) { - throw new Error(`[build-all] Unknown Prebid user ID module in preset: ${moduleName}`); - } - validateUserIdImport(entry); - return entry; - }); - - const imports = selectedEntries.map((entry) => `import '${entry.importPath}';`); - - const content = [ - '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', - '//', - '// Deterministic Prebid.js user ID module preset for attested builds.', - '// TSJS_PREBID_USER_ID_MODULES is intentionally ignored in production builds.', - '// Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments.', - `// Modules: ${moduleNames.join(', ')}`, - '', - ...imports, - '', - ].join('\n'); - - fs.writeFileSync(USER_IDS_FILE, content); - - const manifest = { - prebidVersion: prebidPackageVersion(), - deterministic: !override, - modules: moduleNames, - sourceToModule: sourceToModuleMap(registry.modules), - generatedFileHash: crypto.createHash('sha256').update(content).digest('hex'), - }; - - console.log('[build-all] Prebid user ID modules:', moduleNames); - return manifest; -} - -generatePrebidAdapters(); -const prebidUserIdManifest = generatePrebidUserIdModules(); - -// --------------------------------------------------------------------------- - // Clean dist directory fs.rmSync(distDir, { recursive: true, force: true }); fs.mkdirSync(distDir, { recursive: true }); -fs.writeFileSync(USER_IDS_MANIFEST_FILE, `${JSON.stringify(prebidUserIdManifest, null, 2)}\n`); // Discover integration modules: directories in src/integrations/ with index.ts const integrationModules = fs.existsSync(integrationsDir) @@ -252,11 +34,12 @@ const integrationModules = fs.existsSync(integrationsDir) .filter((name) => { const fullPath = path.join(integrationsDir, name); return ( - fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) + name !== 'prebid' && + fs.statSync(fullPath).isDirectory() && + fs.existsSync(path.join(fullPath, 'index.ts')) ); }) - .sort() - : []; + : []; console.log('[build-all] Discovered integrations:', integrationModules); @@ -268,20 +51,6 @@ async function buildModule(name, entryPath) { await build({ configFile: false, root: __dirname, - resolve: { - alias: { - [LIVE_INTENT_SHIM_ALIAS]: LIVE_INTENT_SHIM, - 'prebid.js/modules/liveIntentIdSystem': LIVE_INTENT_SHIM, - 'tsjs-prebid/liveIntentIdSystemStandard': PREBID_LIVE_INTENT_STANDARD, - 'tsjs-prebid/prebidGlobal': PREBID_GLOBAL_MODULE, - // prebid.js doesn't expose src/adapterManager.js via its package - // "exports" map, but we need it for client-side bidder validation. - 'prebid.js/src/adapterManager.js': path.resolve( - __dirname, - 'node_modules/prebid.js/dist/src/src/adapterManager.js' - ), - }, - }, build: { emptyOutDir: false, outDir: distDir, diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs new file mode 100644 index 000000000..e3bb5ab3c --- /dev/null +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -0,0 +1,293 @@ +/** + * Build a publisher-specific external Prebid bundle. + * + * Unlike build-all.mjs, this script is intended to run outside the Cargo build. + * It produces an immutable bundle and manifest that can be hosted on an asset + * CDN, then referenced by integrations.prebid.external_bundle_url. + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +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 USER_ID_REGISTRY_FILE = path.join(prebidDir, 'user_id_modules.json'); +const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; +const PREBID_PACKAGE_DIR = path.join(__dirname, 'node_modules', 'prebid.js'); +const PREBID_LIVE_INTENT_STANDARD = path.join( + PREBID_PACKAGE_DIR, + 'dist', + 'src', + 'libraries', + 'liveIntentId', + 'idSystem.js' +); +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'); + +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 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}`); + } + if (inlineValue === undefined) { + i += 1; + } + options.set(rawKey, value); + } + + return { + adapters: parseList(options.get('adapters') ?? DEFAULT_PREBID_ADAPTERS), + userIdModules: options.has('user-id-modules') + ? parseList(options.get('user-id-modules')) + : null, + outDir: path.resolve(process.cwd(), options.get('out') ?? path.join('..', 'dist', 'prebid')), + }; +} + +function parseList(raw) { + return raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +function requireExistingFile(filePath, description) { + if (!fs.existsSync(filePath)) { + throw new Error(`[build-prebid-external] Missing ${description}: ${filePath}`); + } +} + +function prebidPackageVersion() { + const packageJsonPath = path.join(PREBID_PACKAGE_DIR, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version; +} + +function readUserIdRegistry() { + return JSON.parse(fs.readFileSync(USER_ID_REGISTRY_FILE, 'utf8')); +} + +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'); + + if (entry.moduleName === 'liveIntentIdSystem') { + return; + } + + try { + require.resolve(entry.importPath, { paths: [__dirname] }); + } catch (error) { + throw new Error( + `[build-prebid-external] Required Prebid user ID module "${entry.moduleName}" ` + + `could not be resolved from ${entry.importPath}: ${error.message}` + ); + } +} + +function writeGeneratedModule(filePath, title, moduleNames, imports) { + const content = [ + '// Auto-generated by build-prebid-external.mjs.', + '//', + title, + `// Modules: ${moduleNames.join(', ')}`, + '', + ...imports, + '', + ].join('\n'); + + fs.writeFileSync(filePath, content); +} + +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 + ); + return validAdapters; +} + +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}`); + } + validateUserIdImport(entry); + return entry; + }); + + const imports = selectedEntries.map((entry) => `import '${entry.importPath}';`); + writeGeneratedModule( + userIdsFile, + '// External Prebid bundle User ID module imports.', + moduleNames, + imports + ); + return moduleNames; +} + +function createTemporaryModulePaths() { + const temporaryDir = fs.mkdtempSync(path.join(prebidDir, '.external-generated-')); + return { + temporaryDir, + adaptersFile: path.join(temporaryDir, '_adapters.generated.ts'), + userIdsFile: path.join(temporaryDir, '_user_ids.generated.ts'), + }; +} + +export function deriveBundleMetadata(bundleBytes) { + const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); + const sri = `sha384-${crypto.createHash('sha384').update(bundleBytes).digest('base64')}`; + const filename = `trusted-prebid-${sha256}.js`; + + return { filename, sha256, sri }; +} + +async function buildExternalBundle(outDir, generatedModules) { + fs.mkdirSync(outDir, { recursive: true }); + + const temporaryFile = `trusted-prebid-${process.pid}-${crypto.randomUUID()}.tmp.js`; + const temporaryPath = path.join(outDir, temporaryFile); + let renamedTemporaryFile = false; + + try { + const { build } = await import('vite'); + + await build({ + configFile: false, + 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 }, + { + find: 'tsjs-prebid/liveIntentIdSystemStandard', + replacement: PREBID_LIVE_INTENT_STANDARD, + }, + { find: 'tsjs-prebid/prebidGlobal', replacement: PREBID_GLOBAL_MODULE }, + { + find: 'prebid.js/src/adapterManager.js', + replacement: path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adapterManager.js' + ), + }, + ], + }, + build: { + emptyOutDir: false, + outDir, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: path.join(prebidDir, 'index.ts'), + output: { + format: 'iife', + dir: outDir, + entryFileNames: temporaryFile, + inlineDynamicImports: true, + extend: false, + name: 'tsjs_prebid_external', + }, + }, + }, + logLevel: 'warn', + }); + + const bundleBytes = fs.readFileSync(temporaryPath); + const metadata = deriveBundleMetadata(bundleBytes); + const finalPath = path.join(outDir, metadata.filename); + + fs.rmSync(finalPath, { force: true }); + fs.renameSync(temporaryPath, finalPath); + renamedTemporaryFile = true; + + return metadata; + } finally { + if (!renamedTemporaryFile) { + fs.rmSync(temporaryPath, { force: true }); + } + } +} + +export async function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const generatedModules = createTemporaryModulePaths(); + + try { + const adapters = generateAdapterImports(args.adapters, generatedModules.adaptersFile); + const userIdModules = generateUserIdImports(args.userIdModules, generatedModules.userIdsFile); + const bundle = await buildExternalBundle(args.outDir, generatedModules); + const manifest = { + prebidVersion: prebidPackageVersion(), + adapters, + userIdModules, + sha256: bundle.sha256, + sri: bundle.sri, + filename: bundle.filename, + }; + + fs.writeFileSync( + path.join(args.outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + console.log('[build-prebid-external] Built external Prebid bundle:', bundle.filename); + console.log('[build-prebid-external] SHA-256:', bundle.sha256); + console.log('[build-prebid-external] SRI:', bundle.sri); + console.log('[build-prebid-external] Manifest:', path.join(args.outDir, 'manifest.json')); + } finally { + fs.rmSync(generatedModules.temporaryDir, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + await main(); +} diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 9beca211a..2ffed57e7 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -6,6 +6,7 @@ "description": "Trusted Server tsjs TypeScript library with queue and simple banner rendering.", "scripts": { "build": "node build-all.mjs", + "build:prebid-external": "node build-prebid-external.mjs", "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/_adapters.generated.ts b/crates/trusted-server-js/lib/src/integrations/prebid/_adapters.generated.ts index e73f6aeaa..eca3dc4e9 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/_adapters.generated.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/_adapters.generated.ts @@ -1,6 +1,6 @@ -// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// Placeholder for generated Prebid adapter imports. // -// Controls which Prebid.js bid adapters are included in the bundle. -// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list -// of adapter names (e.g. "rubicon,appnexus,openx") before building. -// Default: no adapters +// build-prebid-external.mjs aliases this module to a temporary file containing +// publisher-specific imports during external bundle generation. + +export {}; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts index 24e802850..e9fa55f7d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts @@ -1,16 +1,6 @@ -// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// Placeholder for generated Prebid User ID module imports. // -// Deterministic Prebid.js user ID module preset for attested builds. -// TSJS_PREBID_USER_ID_MODULES is intentionally ignored in production builds. -// Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments. -// Modules: connectIdSystem, criteoIdSystem, id5IdSystem, identityLinkIdSystem, liveIntentIdSystem, pubProvidedIdSystem, sharedIdSystem, uid2IdSystem, unifiedIdSystem +// build-prebid-external.mjs aliases this module to a temporary file containing +// publisher-specific imports during external bundle generation. -import 'prebid.js/modules/connectIdSystem.js'; -import 'prebid.js/modules/criteoIdSystem.js'; -import 'prebid.js/modules/id5IdSystem.js'; -import 'prebid.js/modules/identityLinkIdSystem.js'; -import 'prebid.js/modules/liveIntentIdSystem.js'; -import 'prebid.js/modules/pubProvidedIdSystem.js'; -import 'prebid.js/modules/sharedIdSystem.js'; -import 'prebid.js/modules/uid2IdSystem.js'; -import 'prebid.js/modules/unifiedIdSystem.js'; +export {}; 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 6038f42a4..7570ceeee 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -19,13 +19,11 @@ import 'prebid.js/modules/consentManagementUsp.js'; import 'prebid.js/modules/userId.js'; // Client-side bid adapters — self-register with prebid.js on import. -// The set of adapters is controlled by the TSJS_PREBID_ADAPTERS env var at -// build time. See _adapters.generated.ts (written by build-all.mjs). -// User ID submodules come from the deterministic attested preset in -// user_id_modules.json. See _user_ids.generated.ts. -// When a bidder is listed in `client_side_bidders` in trusted-server.toml, -// the requestBids shim leaves its bids untouched and the corresponding -// adapter handles them natively in the browser. +// The external bundle generator aliases these placeholder modules to temporary +// modules built from its --adapters and --user-id-modules options. When a bidder +// is listed in `client_side_bidders` in trusted-server.toml, the requestBids +// shim leaves its bids untouched and the corresponding adapter handles them +// natively in the browser. import './_adapters.generated'; import './_user_ids.generated'; @@ -156,7 +154,9 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { } for (const name of missingConfiguredUserIdNames) { - log.warn(`[tsjs-prebid] configured User ID module "${name}" is not included in TSJS`); + log.warn( + `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` + ); } return diagnostics; @@ -462,21 +462,21 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // Validate that every client-side bidder has its adapter registered. // Adapters self-register on import, so a missing adapter means the bidder - // was listed in client_side_bidders but not in TSJS_PREBID_ADAPTERS at - // build time. Without the adapter the bidder is silently dropped from both - // server-side and client-side auctions. + // was listed in client_side_bidders but not included in the generated + // external Prebid bundle. Without the adapter the bidder is silently dropped + // from both server-side and client-side auctions. for (const bidder of clientSideBidders) { try { if (!adapterManager.getBidAdapter(bidder)) { log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to TSJS_PREBID_ADAPTERS at build time.` + `Add it to build-prebid-external.mjs --adapters.` ); } } catch { log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to TSJS_PREBID_ADAPTERS at build time.` + `Add it to build-prebid-external.mjs --adapters.` ); } } 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 new file mode 100644 index 000000000..609bdba91 --- /dev/null +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -0,0 +1,26 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { deriveBundleMetadata, parseArgs } from '../build-prebid-external.mjs'; + +describe('build-prebid-external metadata', () => { + 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'); + const sri = `sha384-${crypto.createHash('sha384').update(bundleBytes).digest('base64')}`; + + expect(deriveBundleMetadata(bundleBytes)).toEqual({ + filename: `trusted-prebid-${sha256}.js`, + sha256, + sri, + }); + }); + + 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/osano/index.test.ts b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts index 932bda229..891fd5540 100644 --- a/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts @@ -171,6 +171,21 @@ describe('integrations/osano consent mirror', () => { expect(getCookie(MARKER_COOKIE)).toBe('osano'); }); + it('mirrors USP, GPP, and TCF consent cookies together', async () => { + setUspApi('1YN-'); + setGppApi('DBABLA~BVQqAAAAAgA.QA', [7, 8]); + setTcfApi('CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(true); + expect(getCookie('us_privacy')).toBe('1YN-'); + expect(getCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); + expect(getCookie('__gpp_sid')).toBe('7,8'); + expect(getCookie('euconsent-v2')).toBe('CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + it('preserves existing unmarked consent cookies', async () => { document.cookie = 'us_privacy=external; path=/'; setUspApi('1YN-'); diff --git a/docs/guide/cli.md b/docs/guide/cli.md index d745c89d0..3ef29fcec 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -119,3 +119,38 @@ ts audit https://publisher.example --force `ts audit` is not an EdgeZero adapter command. It has no `--adapter` option and it does not provision resources, push config, build, deploy, or contact platform APIs. + +## Generate an external Prebid bundle + +`ts prebid bundle` builds the local external Prebid browser bundle configured in +`trusted-server.toml`. + +```toml +[integrations.prebid.bundle] +adapters = ["rubicon", "kargo"] +user_id_modules = ["sharedIdSystem"] +``` + +Run the command after installing JS dependencies: + +```bash +cd crates/trusted-server-js/lib && npm ci +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 +`proxy.allowed_domains` before running `ts config validate` or `ts config push`. + +Use custom paths when needed: + +```bash +ts prebid bundle --config publisher-a.toml --out build/prebid +``` + +`ts prebid bundle` is local-only. It has no `--adapter` option and does not +upload, provision, deploy, or push config. diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 6d193279a..4280a2789 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -748,7 +748,7 @@ Each integration is built as a separate IIFE at compile time (`crates/trusted-se - `tsjs-core.js` — Core API (always included) - `tsjs-creative.js` — Creative click-guard and tracking -- `tsjs-prebid.js` — Prebid.js NPM bundle with trustedServer adapter +- Prebid is built externally with `build-prebid-external.mjs` and served through `/integrations/prebid/bundle.js` - `tsjs-lockr.js`, `tsjs-permutive.js`, `tsjs-didomi.js`, `tsjs-datadome.js`, `tsjs-testlight.js` — Other integrations At runtime, the server concatenates `tsjs-core.js` + the modules for enabled integrations. The URL stays `/static/tsjs=tsjs-unified.min.js?v=` for backward compatibility. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index db28c7e09..4346fd7e0 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -212,7 +212,7 @@ impl IntegrationScriptRewriter for MyIntegration { `html_processor.rs` calls these hooks after applying the standard origin→first-party rewrite, so you can simply swap URLs, append query parameters, or mutate inline JSON. Use this to point ` +``` + +If `external_bundle_sha256` is omitted, the injected script tag should omit the +content hash query value and Trusted Server must not serve the response as an +immutable asset. + +### Why Not Use `/first-party/proxy` Directly? + +The generic first-party proxy is designed for creative assets. It may forward EC +IDs, follow creative-oriented response processing paths, and uses signed target +URLs. The Prebid bundle is a static application asset and should have a narrower +route with asset-specific behavior. + +The new route can still reuse the lower-level proxy helper, but it should call it +with asset-safe options: + +- `forward_ec_id = false` +- `copy_request_headers = false` or a minimal static-asset header set +- `stream_passthrough = true` +- redirects allowed only when every hop remains `https://` and the redirect + target host is permitted by `proxy.allowed_domains` +- no HTML/CSS rewriting + +## Runtime Request Flow + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant TS as Trusted Server + participant CDN as External Bundle Host + participant PBS as Prebid Server + + B->>TS: GET publisher page + TS->>TS: remove configured publisher Prebid script tags + TS-->>B: HTML with window.__tsjs_prebid and first-party Prebid bundle script + B->>TS: GET /integrations/prebid/bundle.js[?v=sha256] + TS->>CDN: GET external_bundle_url + CDN-->>TS: generated Prebid bundle bytes + TS-->>B: application/javascript with cache headers for configured mode + B->>B: Prebid installs trustedServer adapter and processes pbjs queue + B->>TS: POST /auction + TS->>PBS: POST OpenRTB request + PBS-->>TS: OpenRTB response + TS-->>B: auction response +``` + +## HTML Injection Behavior + +When Prebid is enabled, Prebid head injection should emit: + +1. the existing `window.pbjs` queue stub +2. `window.__tsjs_prebid` config +3. a first-party script tag for `/integrations/prebid/bundle.js`, with `?v=` when a SHA-256 hash is configured + +The script tag should be injected at the same early head insertion point used by +current TSJS injection. + +The generated external Prebid bundle should be `defer`-safe. It must install all +modules and the `trustedServer` adapter before calling `pbjs.processQueue()`. + +## Script Interception Behavior + +In Phase 1, Trusted Server always owns Prebid loading through the generated +external bundle. Therefore existing publisher Prebid script tags should continue +to be removed when they match `script_patterns`. + +Requests for intercepted publisher Prebid script URLs may continue returning the +existing empty JS response. This prevents duplicate Prebid instances when the +publisher page references its original Prebid asset. + +Publisher-existing Prebid mode is explicitly out of scope for Phase 1. + +## External Bundle Generation + +Add a generation path outside the Cargo build, for example: + +```bash +node crates/trusted-server-js/lib/build-prebid-external.mjs \ + --adapters exampleBidder,anotherExampleBidder \ + --user-id-modules sharedIdSystem,uid2IdSystem \ + --out dist/prebid/ +``` + +The generated bundle should include: + +- Prebid.js core +- selected bidder adapters +- consent modules required by the integration +- selected User ID modules +- the existing Trusted Server Prebid adapter/shim logic + +The generator should emit a manifest: + +```json +{ + "prebidVersion": "10.26.0", + "adapters": ["exampleBidder", "anotherExampleBidder"], + "userIdModules": ["sharedIdSystem", "uid2IdSystem"], + "sha256": "abc123...", + "sri": "sha384-...", + "filename": "trusted-prebid-abc123.js" +} +``` + +Trusted Server config should reference the generated asset URL. When the +manifest includes hash values, config should also reference those values to +enable content-addressed delivery, immutable caching, and browser SRI when +configured. + +## Required Code Changes + +### JS Build + +- Stop including `src/integrations/prebid/index.ts` in the default `build-all.mjs` + embedded TSJS discovery path, or move the Prebid external entrypoint outside + `src/integrations`. +- Move reusable Trusted Server Prebid adapter/shim code into a module that can be + used by the external bundle generator. +- Keep Prebid-related generated adapter/User ID imports in the external bundle + generator, not the embedded Trusted Server build. + +### Rust Integration + +- Add `external_bundle_url`, `external_bundle_sha256`, and + `external_bundle_sri` fields to `PrebidIntegrationConfig`. +- Do not register Prebid with `.with_deferred_js()`. +- Register a Prebid integration GET route for `/integrations/prebid/bundle.js`. +- Implement the route as a first-party proxy to `external_bundle_url` with static + asset behavior. +- Inject the first-party script tag from the Prebid head injector. +- Preserve current script-pattern removal/empty-script behavior. + +### Publisher Static Serving + +- `/static/tsjs=tsjs-prebid.min.js` should no longer be a Prebid loading path. +- Existing deferred-module serving can remain for other integrations. + +## Response Headers + +For successful first-party bundle responses, Trusted Server should always set or +normalize: + +```text +Content-Type: application/javascript; charset=utf-8 +``` + +Caching depends on whether the browser-visible URL is content-addressed. + +When `external_bundle_sha256` is configured, the injected URL should include +`?v=` and Trusted Server should serve the response as an +immutable asset: + +```text +Cache-Control: public, max-age=31536000, immutable +ETag: "sha256:" +``` + +If the route query `v` is present, it must match `external_bundle_sha256`. If no +SHA-256 is configured, any `v` query value should return `404 Not Found`. This +avoids ambiguous cache entries. + +When `external_bundle_sha256` is omitted, the injected URL should not include a +content hash query value and Trusted Server must not use `immutable`. It should +use a short-lived revalidation-oriented policy, for example: + +```text +Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 +``` + +In this mode, validators may be omitted. Phase 1 preserves streaming passthrough, +so Trusted Server should not buffer the bundle solely to compute an `ETag`. + +## Integrity and Attestation + +This design separates two attestable artifacts: + +1. Trusted Server WASM binary +2. generated external Prebid bundle + +The Trusted Server binary hash should no longer vary with Prebid module choices. +The Prebid bundle should be audited through its own manifest containing: + +- Prebid version +- module list +- bundle hash +- SRI value +- generator version or source revision when available + +When configured, browser SRI validates the first-party proxied response. SRI is +recommended with `external_bundle_sha256`, but it is not required; deployments may +use the hash metadata only for versioned first-party URLs, cache policy, and +manifest auditing. + +Phase 1 does not perform mandatory edge-side SHA-256 byte validation. Doing so +would require buffering the proxied bundle and would break the streaming +passthrough behavior for this route. If `external_bundle_sha256` is omitted, +Trusted Server cannot treat the route as content-addressed. That mode trades +stronger attestation and long-lived caching for easier operations and must use +non-immutable cache headers. + +## Migration Plan + +1. Add required external bundle URL config and optional hash/SRI metadata. +2. Add first-party bundle proxy route and injection. +3. Add external bundle generation tooling and manifest output. +4. Remove Prebid from the embedded TSJS build and never register it as deferred JS. +5. Update docs and examples to point publishers at the generated external bundle. + +## Test Plan + +### Rust Tests + +- Config validation accepts valid external bundle settings. +- Config validation rejects missing required external bundle settings and malformed optional hash or SRI metadata. +- Registry does not include `prebid` in embedded or deferred JS IDs. +- Head injection emits the first-party bundle URL with the configured hash when present and without a hash query value when absent. +- Script interception still removes matching publisher Prebid scripts. +- Bundle route proxies to `external_bundle_url` without forwarding EC ID. +- Bundle route rejects mismatched `v` query values when SHA-256 is configured and rejects any `v` query value when SHA-256 is omitted. +- Bundle route blocks redirects to non-HTTPS URLs. +- Bundle route blocks redirects whose target hosts are not permitted by + `proxy.allowed_domains`. +- Bundle route emits JavaScript content type and immutable cache headers in content-addressed mode. +- Bundle route emits JavaScript content type and non-immutable short-lived cache headers when SHA-256 is omitted. + +### JS Tests + +- External generated bundle registers the `trustedServer` adapter. +- External generated bundle shims `requestBids()` as the previous embedded bundle + did. +- External generated bundle calls `pbjs.processQueue()` after module/adapter + registration. +- Client-side bidder adapter selection is reflected in the generated manifest. + +### Browser/Integration Tests + +- Publisher page loads no `/static/tsjs=tsjs-prebid.min.js`. +- Browser loads `/integrations/prebid/bundle.js?v=` from first-party + origin when SHA-256 is configured, or `/integrations/prebid/bundle.js` when it + is omitted. +- Original publisher Prebid script tag is removed or neutralized. +- A Prebid auction still posts to `/auction`. +- No duplicate Prebid instances are created. + +## Final Phase 1 Decisions + +- Edge-side SHA-256 byte validation is not mandatory in Phase 1 because the route + preserves streaming passthrough. +- Redirects are allowed only when every hop remains `https://` and each redirect + target host is permitted by `proxy.allowed_domains`. +- The injected script tag omits `crossorigin` because the browser-visible bundle + URL is same-origin. diff --git a/docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md b/docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md new file mode 100644 index 000000000..69a8f35de --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md @@ -0,0 +1,418 @@ +# Trusted Server CLI — Prebid Bundle Generation + +**Date:** 2026-06-17 +**Status:** Implemented +**Scope:** `ts prebid bundle` local external Prebid bundle generation +**Related context:** + +- `docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md` +- `docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md` +- `crates/trusted-server-js/lib/build-prebid-external.mjs` +- `crates/trusted-server-js/lib/package.json` + +--- + +## 1. Goal + +Add a Trusted Server-specific CLI command for generating the external Prebid +browser bundle used by the first-party Prebid proxy flow: + +```bash +ts prebid bundle +``` + +The command should make the existing external bundle generation path ergonomic for +operators by reading bundle selections from `trusted-server.toml`, running the +existing JS/Vite generator, writing local build artifacts to `dist/prebid` by +default, and updating `trusted-server.toml` with the generated bundle integrity +metadata. + +The command is intentionally a Trusted Server product command, not an EdgeZero +lifecycle command. It does not require `--adapter`, does not upload assets, and +does not provision or deploy platform resources. + +The external Prebid runtime model remains the one defined by the first-party +proxy spec: + +1. Prebid is not embedded in the Trusted Server WASM/TSJS bundle. +2. A generated external Prebid bundle is hosted by the operator. +3. Trusted Server injects `/integrations/prebid/bundle.js[?v=]`. +4. Trusted Server proxies that first-party URL to `integrations.prebid.external_bundle_url`. + +--- + +## 2. Non-goals + +The initial `ts prebid bundle` command does **not** do any of the following: + +- upload generated bundles to an asset host or CDN; +- infer or construct the public `external_bundle_url`; +- accept an asset base URL and derive hosted URLs; +- call EdgeZero adapter lifecycle commands; +- require or accept `--adapter`; +- push `trusted-server.toml` to a config store; +- run `npm install`, `npm ci`, or otherwise mutate JS dependencies; +- port the Prebid bundler from Node/Vite into Rust; +- change the generated `manifest.json` schema; +- change the first-party proxy runtime behavior; +- generate arbitrary Prebid runtime module choices at the edge; +- support remote or platform-hosted bundling. + +--- + +## 3. Command surface + +```bash +ts prebid bundle [--config ] [--out ] +``` + +Defaults: + +| Option | Default | Description | +| ---------- | --------------------- | ---------------------------------------------- | +| `--config` | `trusted-server.toml` | Trusted Server app config to read and update | +| `--out` | `dist/prebid` | Local output directory for generated artifacts | + +Examples: + +```bash +# Generate from trusted-server.toml into dist/prebid +ts prebid bundle + +# Generate from a draft config +ts prebid bundle --config ./publisher-a.trusted-server.toml + +# Generate into a custom local directory +ts prebid bundle --out ./build/prebid +``` + +Successful output should be concise and actionable, for example: + +```text +Built Prebid bundle: dist/prebid/trusted-prebid-.js +Manifest: dist/prebid/manifest.json +Updated config: trusted-server.toml +Next: upload the bundle and set integrations.prebid.external_bundle_url to its HTTPS URL if needed. +``` + +The default output directory `dist/prebid` is a local generated-artifact path and +must be git-ignored at the repository root. + +--- + +## 4. Trusted Server config schema + +Bundle-generation selections live in `trusted-server.toml` under the Prebid +integration block: + +```toml +[integrations.prebid] +enabled = true +server_url = "https://prebid-server.example.com/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" + +[proxy] +# Required for external_bundle_url. Include the bundle host and any HTTPS redirect targets. +allowed_domains = ["assets.example.com"] + +[integrations.prebid.bundle] +adapters = ["rubicon", "kargo"] +user_id_modules = ["sharedIdSystem", "uid2IdSystem"] +``` + +### 4.1 Field semantics + +| Field | Required | Description | +| -------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `integrations.prebid.bundle.adapters` | Yes | Prebid bidder adapter module names passed to the external bundle generator, e.g. `rubicon` -> `rubiconBidAdapter.js` | +| `integrations.prebid.bundle.user_id_modules` | No | Prebid User ID module names passed to the external bundle generator. When omitted, the JS generator's default preset is used | + +The bundle config is intentionally separate from existing runtime fields: + +- `integrations.prebid.bidders` controls server-side bidders routed through the + Trusted Server / Prebid Server auction flow. +- `integrations.prebid.client_side_bidders` controls browser-side bidder behavior + in the injected Prebid client config. +- `integrations.prebid.bundle.adapters` controls which native Prebid.js adapter + modules are statically imported into the generated external browser bundle. + +Operators may choose to keep `client_side_bidders` and `bundle.adapters` aligned, +but the CLI should not infer one from the other in this initial design. + +### 4.2 Config compatibility + +The existing runtime fields remain unchanged: + +```toml +[integrations.prebid] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +external_bundle_sha256 = "..." +external_bundle_sri = "sha384-..." +``` + +`external_bundle_url` remains manually authored by the operator. The CLI does not +infer it from `--out` and does not know where the operator will upload the local +bundle. Runtime validation requires `proxy.allowed_domains` to include the hosted +bundle URL's host and any HTTPS redirect targets. + +--- + +## 5. Config update behavior + +After a successful local bundle build, `ts prebid bundle` must read the generated +`manifest.json` and update the same `trusted-server.toml` file with: + +```toml +[integrations.prebid] +external_bundle_sha256 = "" +external_bundle_sri = "" +``` + +The command must preserve `external_bundle_url` as-is. It must not overwrite, +derive, or remove the URL. + +If `external_bundle_url` is absent, the command may still generate the bundle and +write `external_bundle_sha256` / `external_bundle_sri`, but it must report a clear +next step telling the operator to set `integrations.prebid.external_bundle_url` to +the hosted HTTPS URL before validation/push/deploy. + +If `external_bundle_url` points at an old content-addressed filename, the command +must not guess the replacement. It should print the generated filename and remind +the operator to update `external_bundle_url` manually after upload. + +Config writes should be atomic: write to a temporary file next to the config and +rename into place after serialization succeeds. The implementation should prefer +a TOML editing library such as `toml_edit` so comments, ordering, and unrelated +formatting are preserved as much as practical. + +--- + +## 6. Local build behavior + +The Rust CLI should shell out to the existing JS bundler instead of reimplementing +Prebid/Vite bundling in Rust. + +Expected invocation model: + +1. Locate `crates/trusted-server-js/lib/package.json` and `crates/trusted-server-js/lib/build-prebid-external.mjs` + relative to the repository root/current working tree. +2. Read `[integrations.prebid.bundle]` from the selected config file. +3. Convert configured lists to the existing generator's CSV arguments. +4. Run the existing npm script from `crates/trusted-server-js/lib`: + +```bash +npm run build:prebid-external -- \ + --adapters rubicon,kargo \ + --user-id-modules sharedIdSystem,uid2IdSystem \ + --out +``` + +If `user_id_modules` is omitted, the CLI should omit `--user-id-modules` so the +JS generator uses its existing default preset. + +The generated output remains the current JS generator output: + +```text +dist/prebid/ + trusted-prebid-.js + manifest.json +``` + +The manifest schema remains unchanged: + +```json +{ + "prebidVersion": "10.26.0", + "adapters": ["rubicon", "kargo"], + "userIdModules": ["sharedIdSystem", "uid2IdSystem"], + "sha256": "abc123...", + "sri": "sha384-...", + "filename": "trusted-prebid-abc123.js" +} +``` + +--- + +## 7. Dependency and environment handling + +`ts prebid bundle` should fail fast with actionable diagnostics when local JS +build prerequisites are missing. + +Minimum checks before shelling out: + +- `npm` is available on `PATH`; +- `crates/trusted-server-js/lib/package.json` exists; +- `crates/trusted-server-js/lib/build-prebid-external.mjs` exists; +- `crates/trusted-server-js/lib/node_modules` exists. + +If `node_modules` is missing, the command must not run dependency installation. +It should fail with an instruction like: + +```text +Prebid bundling dependencies are missing. Run `cd crates/trusted-server-js/lib && npm ci`, then retry `ts prebid bundle`. +``` + +Errors from the JS generator, including unknown adapter names or unknown User ID +module names, should be surfaced without hiding the original generator message. +The CLI may add a short Trusted Server context prefix, but should preserve stdout +and stderr enough for debugging. + +--- + +## 8. Config loading and validation + +`ts prebid bundle` should not require full production config validity. It is a +local artifact-generation command, and operators may run it before the config is +ready for `ts config validate` or `ts config push`. + +The command should perform focused validation only for the fields it needs: + +- selected config file exists and parses as TOML; +- `[integrations.prebid]` exists; +- `[integrations.prebid.bundle]` exists; +- `bundle.adapters` is a non-empty array of non-empty strings; +- `bundle.user_id_modules`, when present, is an array of non-empty strings; +- `--out` resolves to a writable local directory path. + +The JS generator remains responsible for validating that adapter and User ID +module names correspond to available Prebid package modules. + +After the command updates hash/SRI metadata, `ts config validate` remains the +source of truth for full deployment readiness, including `external_bundle_url` +requirements, placeholder secret rejection, and runtime config validation. + +--- + +## 9. Integration with existing CLI design + +This spec extends the `ts` product CLI command surface with a new Trusted +Server-specific command group: + +```text +ts prebid bundle +``` + +The resulting CLI command enum should conceptually become: + +```text +ts audit ... +ts config ... +ts prebid bundle ... +ts auth ... +ts provision ... +ts serve ... +ts build ... +ts deploy ... +``` + +`ts prebid bundle` is similar to `ts audit` and `ts config` in that it owns +Trusted Server behavior directly. It is unlike `ts build` / `ts deploy`, which +are EdgeZero lifecycle delegates. + +--- + +## 10. Required code changes + +### CLI argument parsing + +- Add `Command::Prebid(PrebidArgs)`. +- Add `PrebidCommand::Bundle(PrebidBundleArgs)`. +- Add options: + - `--config ` defaulting to `trusted-server.toml`; + - `--out ` defaulting to `dist/prebid`. +- Add parser tests for defaults and custom paths. +- Reject `--adapter` for `ts prebid bundle`. + +### CLI implementation + +- Add a Prebid bundle command module, for example + `crates/trusted-server-cli/src/prebid_bundle.rs`. +- Parse focused bundle config from TOML. +- Check local JS dependency prerequisites. +- Shell out to `npm run build:prebid-external -- ...` in `crates/trusted-server-js/lib`. +- Read generated `manifest.json`. +- Atomically update `external_bundle_sha256` and `external_bundle_sri` in the + selected config file. +- Print concise success output and next steps. + +### JS tooling + +No manifest format change is required. + +The existing `crates/trusted-server-js/lib/build-prebid-external.mjs` should remain the source +of truth for generating the bundle, validating adapter module files, validating +User ID module names, hashing bundle bytes, and writing `manifest.json`. + +### Git ignore + +- Add `/dist/prebid/` to the repository root `.gitignore`. + +--- + +## 11. Test plan + +### CLI parser tests + +- `ts prebid bundle` parses with defaults: + - config: `trusted-server.toml` + - out: `dist/prebid` +- `ts prebid bundle --config publisher.toml --out build/prebid` parses custom paths. +- `ts prebid bundle --adapter fastly` is rejected. + +### Unit tests + +- Bundle config loader accepts valid `[integrations.prebid.bundle]` settings. +- Bundle config loader rejects missing Prebid block. +- Bundle config loader rejects missing bundle block. +- Bundle config loader rejects empty or malformed adapter arrays. +- Config patcher writes `external_bundle_sha256` and `external_bundle_sri`. +- Config patcher preserves existing `external_bundle_url`. +- Config patcher creates missing `external_bundle_sha256` / `external_bundle_sri` + fields when absent. +- Missing `node_modules` fails with an instruction to run `cd crates/trusted-server-js/lib && npm ci`. + +### Integration-style CLI tests + +- With a fake shell delegate/process runner, the command invokes: + - program: `npm` + - cwd: `crates/trusted-server-js/lib` + - args: `run build:prebid-external -- --adapters ... --out ...` +- When `user_id_modules` is omitted, `--user-id-modules` is not passed. +- When the fake generator writes `manifest.json`, the selected config is patched + from that manifest. +- Generator failure returns a CLI error and does not update config. + +### Manual smoke test + +```bash +cd crates/trusted-server-js/lib +npm ci +cd ../../.. + +ts prebid bundle +ls dist/prebid +rg 'external_bundle_sha256|external_bundle_sri' trusted-server.toml +``` + +Then upload the generated JS file manually, set or verify +`integrations.prebid.external_bundle_url`, ensure `proxy.allowed_domains` +contains the bundle host and any HTTPS redirect targets, and run: + +```bash +ts config validate +``` + +--- + +## 12. Open follow-up work + +These are intentionally outside the initial local-only command, but the design +should not preclude them later: + +- optional upload support through EdgeZero/platform asset primitives; +- optional asset URL/base URL handling; +- manifest generator metadata such as Trusted Server CLI version or source + revision; +- stronger checks that `external_bundle_url` corresponds to the generated + content-addressed filename; +- richer JSON output for CI automation. diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh new file mode 100755 index 000000000..eef9e2f7d --- /dev/null +++ b/scripts/test-cli.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST_TARGET="${1:-$(rustc -vV | awk '/host:/ { print $2 }')}" +if [ -z "$HOST_TARGET" ]; then + echo "Failed to detect host target" >&2 + exit 1 +fi + +if ! command -v rustup >/dev/null 2>&1; then + echo "rustup not found; cannot ensure host target $HOST_TARGET is installed" >&2 + echo "Run: cargo test --package trusted-server-cli --target $HOST_TARGET" >&2 + exit 1 +fi + +if ! rustup target list --installed | awk -v target="$HOST_TARGET" '$0 == target { found = 1 } END { exit found ? 0 : 1 }'; then + echo "Installing Rust target: $HOST_TARGET" + rustup target add "$HOST_TARGET" +fi + +cargo test --package trusted-server-cli --target "$HOST_TARGET" diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0e8226efb..9c4562dcb 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -45,6 +45,10 @@ bidders = [] debug = false client_side_bidders = [] +[integrations.prebid.bundle] +adapters = ["rubicon"] +# user_id_modules = ["sharedIdSystem"] + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] @@ -98,7 +102,8 @@ rewrite_script = true [proxy] # certificate_check = true -# allowed_domains = ["ads.example.com", "*.cdn.example.com"] +# Required for integrations.prebid.external_bundle_url and first-party proxy redirects. +# allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] [auction] enabled = false