diff --git a/.cargo/config.toml b/.cargo/config.toml index 55a08bd79b..a0f90ba645 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # Feature-on counterpart of `test-fastly`. `test-fastly` does NOT pass # --all-features, so without this the reusable-sandbox code is linted by # `clippy-fastly` but its tests never execute. @@ -40,8 +40,8 @@ test-fastly-reuse = "test -p trusted-server-adapter-fastly --target wasm32-wasip # run with the Axum adapter in CI. build-axum = "build -p trusted-server-adapter-axum" check-axum = "check -p trusted-server-adapter-axum" -clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb --all-targets --all-features -- -D warnings" -test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb" +clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b6a8a0e18..757dd12565 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -327,3 +327,9 @@ jobs: - name: Run unit tests run: npm test -- --run + + # A module that writes a script into pages tests it in its own folder, + # with Node's test runner and no packages. + - name: Run module page script tests + working-directory: . + run: node --test "crates/*/*/tests/*.test.mjs" diff --git a/AGENTS.md b/AGENTS.md index 318fc212a0..a5dbc8073c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ crates/ auction/ # auction module crates (aps, prebid, prebid-server) audience/ # audience module crates (permutive) bot-protection/ # bot protection module crates (datadome) - cmp/ # consent tool module crates (didomi, osano, sourcepoint) + cmp/ # consent tool module crates (didomi, inmobi, osano, sourcepoint) framework/ # framework module crates (nextjs) identity/ # identity module crates (lockr) tag/ # tag manager module crates (google-tag-manager) diff --git a/CHANGELOG.md b/CHANGELOG.md index 636f094d6a..3ea9f13a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, `cmp_id` unless it is a whole number from 1 to 4095, and entries that leave any page without the prompt are refused. The new `Middleware::template_key` keys a stored page by what a middleware writes that no setting decides, so a change to a stub stores pages again. The Trusted Server maintainers hold the crate until InMobi adopt it. +- The stock modules' fetch middleware give as their `template_key` the text compiled into their crates that they write into a page, so a change to it stores pages again. The Google ad tag gives its head scripts, the `tsjs.adInit` bootstrap among them, and the GPT script's first-party path, Prebid its configuration script and bundle tag, Didomi its settings script, and Sourcepoint its head scripts and its CDN's first-party path. Permutive, Lockr, DataDome and Google Tag Manager give the first-party path they point a vendor's address at. - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module is asked for the host the request was addressed to, never one a forwarded header names, and is given nothing of the crawler's connection. Its answer is held for that host in the request's key-value store until its refresh is due, and no adapter gives a request a working one, so every adapter asks each module on every request. A module that cannot answer has its last held answer served, and with none held, or with no rule in the whole file, the answer is `503` and never an empty file. On Fastly the file is answered before the Edge Cookie steps and the request filters. Without the section `/robots.txt` reaches the publisher's origin as before. - `ts keys rotate` and `ts keys deactivate` rotate and retire the request signing keys from an operator's machine. They write the two stores the service links as `jwks_store` and `signing_keys` through the `fastly` CLI, with the token from `FASTLY_API_TOKEN`. A private key reaches `fastly` on its standard input and never in its arguments, and a key id no platform can store is refused before anything is written. A command that cannot read the config store writes nothing, and a delete that failed part way can be run again. - One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. Each host's blob names its permission policy file and pins its SHA-256, and the push keeps each file `--permissions` supplies once, in a shared entry under `permissions_`, written before the blob, which a live push writes a minute after it writes an entry. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. diff --git a/Cargo.lock b/Cargo.lock index 095db3e3fe..9ed59b900b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6877,6 +6877,18 @@ dependencies = [ "validator", ] +[[package]] +name = "trusted-server-cmp-inmobi" +version = "0.1.0" +dependencies = [ + "error-stack", + "serde", + "serde_json", + "trusted-server-core", + "url", + "validator", +] + [[package]] name = "trusted-server-cmp-osano" version = "0.1.0" @@ -7085,6 +7097,7 @@ dependencies = [ "trusted-server-audience-permutive", "trusted-server-bot-protection-datadome", "trusted-server-cmp-didomi", + "trusted-server-cmp-inmobi", "trusted-server-cmp-osano", "trusted-server-cmp-sourcepoint", "trusted-server-core", diff --git a/Cargo.toml b/Cargo.toml index d4385f538a..a5c40dddf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/audience/permutive", "crates/cmp/sourcepoint", "crates/cmp/didomi", + "crates/cmp/inmobi", "crates/tag/google-tag-manager", "crates/testing/testlight", "crates/framework/nextjs", @@ -161,6 +162,7 @@ trusted-server-identity-lockr = { path = "crates/identity/lockr" } trusted-server-audience-permutive = { path = "crates/audience/permutive" } trusted-server-cmp-sourcepoint = { path = "crates/cmp/sourcepoint" } trusted-server-cmp-didomi = { path = "crates/cmp/didomi" } +trusted-server-cmp-inmobi = { path = "crates/cmp/inmobi" } trusted-server-tag-google-tag-manager = { path = "crates/tag/google-tag-manager" } trusted-server-testing-testlight = { path = "crates/testing/testlight" } trusted-server-framework-nextjs = { path = "crates/framework/nextjs" } diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index 8017393fc4..ecc6e529e7 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -99,6 +99,9 @@ const SECUREPUBADS_HOST: &str = "securepubads.g.doubleclick.net"; /// Integration route prefix for all GPT proxy endpoints. const ROUTE_PREFIX: &str = "/integrations/gpt"; +/// The first-party path a page loads the GPT script from. +const SCRIPT_PATH: &str = "/integrations/gpt/script"; + /// Configuration for the Google Publisher Tags integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] @@ -436,7 +439,7 @@ pub fn register( let mut registration = IntegrationRegistration::builder(GPT_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration.clone()))); + .with_middleware(Arc::new(PageChange::new(integration.clone()))); if let Some((name, value)) = integration.bundle_tag_attribute() { registration = registration.with_bundle_tag_attribute(name, value); } @@ -494,7 +497,21 @@ impl IntegrationProxy for GptIntegration { /// for. It writes the `tsjs.adInit` bootstrap into the head and, when /// `rewrite_script` is set, points the GPT script's address at the /// first-party path. -struct PageChange(Arc); +struct PageChange { + integration: Arc, + /// The head scripts as written, then [`SCRIPT_PATH`]. + template_key: String, +} + +impl PageChange { + fn new(integration: Arc) -> Self { + let template_key = integration.head_markup().concat() + SCRIPT_PATH; + Self { + integration, + template_key, + } + } +} impl Middleware for PageChange { fn middleware_id(&self) -> &'static str { @@ -507,16 +524,22 @@ impl Middleware for PageChange { fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { let mut action = MiddlewareAction { - head_inserts: self.0.head_markup(), + head_inserts: self.integration.head_markup(), ..MiddlewareAction::pass() }; - if self.0.config.rewrite_script { + if self.integration.config.rewrite_script { let decide: Rc = Rc::new(|matched| GptIntegration::rewrite_script_address(matched.value)); action.element_handlers = AttributeRewrite::each(&["src", "href"], &decide); } action } + + /// The head scripts and the script path, because the bootstrap and the + /// path are compiled into this crate and no setting changes when they do. + fn template_key(&self) -> &str { + &self.template_key + } } impl GptIntegration { @@ -529,7 +552,7 @@ impl GptIntegration { // so on pages that set an external base URL these resolve against // that base rather than the address-bar origin — an accepted // tradeoff, matching GTM/Didomi/Testlight which are also relative. - AttributeRewriteAction::replace("/integrations/gpt/script".to_string()) + AttributeRewriteAction::replace(SCRIPT_PATH.to_string()) } else { AttributeRewriteAction::keep() } @@ -640,7 +663,7 @@ mod tests { MiddlewarePhase::Fetch, &document_state, ); - PageChange(integration).create(&context) + PageChange::new(integration).create(&context) } fn build_http_request(method: Method, uri: &str) -> http::Request { @@ -1440,7 +1463,31 @@ mod tests { #[test] fn the_middleware_is_named_as_the_module() { let integration = GptIntegration::new(test_config()); - assert_eq!(PageChange(integration).middleware_id(), MODULE); + assert_eq!(PageChange::new(integration).middleware_id(), MODULE); + } + + /// The bootstrap and the script path are compiled into this crate and no + /// setting changes when they do, so they have to be in the text the + /// registered middleware keys a stored page by, or a page stored before a + /// change to either keeps the old one until it expires. + #[test] + fn the_bootstrap_is_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[super::builder(), super::diagnostics::builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + assert!(key.contains(GPT_BOOTSTRAP_JS), "{key}"); + assert!(key.contains(SCRIPT_PATH), "{key}"); } #[test] diff --git a/crates/auction/prebid/src/lib.rs b/crates/auction/prebid/src/lib.rs index bebe707333..b08e2c82de 100644 --- a/crates/auction/prebid/src/lib.rs +++ b/crates/auction/prebid/src/lib.rs @@ -1103,7 +1103,7 @@ pub fn register_for_plan( Ok(Some( IntegrationRegistration::builder(PREBID_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration))) + .with_middleware(Arc::new(PageChange::new(integration))) .with_deferred_js() .build(), )) @@ -1121,7 +1121,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(PREBID_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration))) + .with_middleware(Arc::new(PageChange::new(integration))) .with_deferred_js() .build(), )) @@ -1191,7 +1191,21 @@ impl IntegrationProxy for PrebidIntegration { /// [`MODULE`] for. It writes the configuration Prebid.js reads and the tag /// that loads the bundle into the head, and removes the publisher's own /// Prebid script. -struct PageChange(Arc); +struct PageChange { + integration: Arc, + /// The head scripts as written. + template_key: String, +} + +impl PageChange { + fn new(integration: Arc) -> Self { + let template_key = integration.head_markup().concat(); + Self { + integration, + template_key, + } + } +} impl Middleware for PageChange { fn middleware_id(&self) -> &'static str { @@ -1203,15 +1217,22 @@ impl Middleware for PageChange { } fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { - let integration = Arc::clone(&self.0); + let integration = Arc::clone(&self.integration); let decide: Rc = Rc::new(move |matched| integration.rewrite_script_address(matched.value)); MiddlewareAction { - head_inserts: self.0.head_markup(), + head_inserts: self.integration.head_markup(), element_handlers: AttributeRewrite::each(&["src", "href"], &decide), ..MiddlewareAction::pass() } } + + /// The head scripts, because the script that hands Prebid.js its + /// configuration and the tag that loads the bundle are compiled into this + /// crate and no setting changes when they do. + fn template_key(&self) -> &str { + &self.template_key + } } impl PrebidIntegration { @@ -3379,6 +3400,49 @@ server_url = "https://prebid.example" ); } + /// The script that hands Prebid.js its configuration and the tag that + /// loads the bundle are compiled into this crate and no setting changes + /// when they do, so what the registered middleware writes into the head + /// has to be in the text it keys a stored page by, or a page stored before + /// a change keeps the old scripts until it expires. + #[test] + fn the_head_scripts_are_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + let document_state = IntegrationDocumentState::default(); + let written = registry + .middleware_chain( + &settings.fetch, + MiddlewarePhase::Fetch, + trusted_server_core::middleware::HTML_MEDIA_TYPE, + "/", + ) + .plan(&trusted_server_core::middleware::test_support::context( + MiddlewarePhase::Fetch, + &document_state, + )) + .expect("should plan the page change") + .head_inserts; + assert!( + written + .iter() + .any(|script| script.contains("window.__tsjs_prebid=")), + "should write the configuration script: {written:?}" + ); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + for script in &written { + assert!(key.contains(script.as_str()), "{script} is not in {key}"); + } + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. diff --git a/crates/audience/permutive/src/lib.rs b/crates/audience/permutive/src/lib.rs index 60c0e4ee20..b87ac9358f 100644 --- a/crates/audience/permutive/src/lib.rs +++ b/crates/audience/permutive/src/lib.rs @@ -462,6 +462,9 @@ impl IntegrationProxy for PermutiveIntegration { } } +/// The first-party path a page loads the SDK from. +const SDK_PATH: &str = "/integrations/permutive/sdk"; + impl PermutiveIntegration { /// What becomes of a `src` or an `href`, which is pointed at the /// first-party endpoint when it is the SDK's address. @@ -473,7 +476,7 @@ impl PermutiveIntegration { // so on pages that set an external base URL these resolve against // that base rather than the address-bar origin — an accepted // tradeoff, matching GTM/Didomi/Testlight which are also relative. - AttributeRewriteAction::replace("/integrations/permutive/sdk".to_string()) + AttributeRewriteAction::replace(SDK_PATH.to_string()) } else { AttributeRewriteAction::keep() } @@ -505,6 +508,12 @@ impl Middleware for SdkAddress { ..MiddlewareAction::pass() } } + + /// The SDK's path, because it is compiled into this crate and no setting + /// changes when it does. + fn template_key(&self) -> &str { + SDK_PATH + } } // Default value functions @@ -761,6 +770,34 @@ mod tests { ); } + /// The SDK's path is compiled into this crate and no setting changes when + /// it does, so it has to be the text the registered middleware keys a + /// stored page by, or a page stored before a change keeps loading the SDK + /// from the old path until it expires. + #[test] + fn the_sdk_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the SDK's middleware should be registered"); + assert_eq!(*key, SDK_PATH); + assert!( + include_str!("fixtures/page-change.recorded.html") + .contains(&format!("src=\"{SDK_PATH}\"")), + "the recorded page should load the SDK from the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. diff --git a/crates/bot-protection/datadome/src/lib.rs b/crates/bot-protection/datadome/src/lib.rs index fcba7c68a7..bba669c719 100644 --- a/crates/bot-protection/datadome/src/lib.rs +++ b/crates/bot-protection/datadome/src/lib.rs @@ -970,6 +970,9 @@ impl Middleware for ClientTag { } } +/// The first-party path a page loads the SDK's scripts under. +const SDK_PREFIX: &str = "/integrations/datadome"; + /// Loads the `DataDome` SDK from the first-party path, on the pages a /// `[[fetch]]` entry names [`MODULE`] for. It does nothing unless /// `rewrite_sdk` is set. @@ -995,6 +998,12 @@ impl Middleware for SdkAddress { ..MiddlewareAction::pass() } } + + /// The path the SDK's scripts are loaded under, because it is compiled + /// into this crate and no setting changes when it does. + fn template_key(&self) -> &str { + SDK_PREFIX + } } impl DataDomeIntegration { @@ -1049,7 +1058,7 @@ impl DataDomeIntegration { // on pages that set an external base URL these resolve against that base // rather than the address-bar origin — an accepted tradeoff, matching // GTM/Didomi/Testlight which are also relative. - let new_url = format!("/integrations/datadome{path}"); + let new_url = format!("{SDK_PREFIX}{path}"); log::info!( "[datadome] Rewriting script src from {} to {}", @@ -2092,6 +2101,35 @@ mod tests { ); } + /// The path the SDK's scripts are loaded under is compiled into this crate + /// and no setting changes when it does, so it has to be the text the + /// registered fetch middleware keys a stored page by, or a page stored + /// before a change keeps loading the SDK from the old path until it + /// expires. + #[test] + fn the_sdk_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the SDK's middleware should be registered"); + assert_eq!(*key, SDK_PREFIX); + assert!( + include_str!("fixtures/page-change.recorded.html") + .contains(&format!("src=\"{SDK_PREFIX}/tags.js\"")), + "the recorded page should load the SDK under the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 5082608d46..1ff33e2dd6 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -439,7 +439,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(DIDOMI_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(ClientConfig(integration))) + .with_middleware(Arc::new(ClientConfig::new(&integration))) .build(), )) } @@ -595,7 +595,18 @@ impl DidomiIntegration { /// Writes the browser module's settings into the head, on the pages a /// `[[fetch]]` entry names [`MODULE`] for. -struct ClientConfig(Arc); +struct ClientConfig { + /// The script, made once when the module registers. + script: String, +} + +impl ClientConfig { + fn new(integration: &DidomiIntegration) -> Self { + Self { + script: integration.client_config_script(), + } + } +} impl Middleware for ClientConfig { fn middleware_id(&self) -> &'static str { @@ -608,10 +619,16 @@ impl Middleware for ClientConfig { fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { MiddlewareAction { - head_inserts: vec![self.0.client_config_script()], + head_inserts: vec![self.script.clone()], ..MiddlewareAction::pass() } } + + /// The script, because its text and the default path in it are compiled + /// into this crate and no setting changes when they do. + fn template_key(&self) -> &str { + &self.script + } } #[cfg(test)] @@ -1386,7 +1403,7 @@ mod tests { &document_state, ); - let action = ClientConfig(integration).create(&context); + let action = ClientConfig::new(&integration).create(&context); assert_eq!( action.head_inserts, @@ -1479,6 +1496,49 @@ mod tests { ); } + /// The settings script and its default path are compiled into this crate + /// and no setting changes when they do, so what the registered middleware + /// writes into the head has to be in the text it keys a stored page by, + /// or a page stored before a change keeps the old script until it + /// expires. + #[test] + fn the_settings_script_is_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + let document_state = trusted_server_core::integrations::IntegrationDocumentState::default(); + let written = registry + .middleware_chain( + &settings.fetch, + MiddlewarePhase::Fetch, + trusted_server_core::middleware::HTML_MEDIA_TYPE, + "/", + ) + .plan(&trusted_server_core::middleware::test_support::context( + MiddlewarePhase::Fetch, + &document_state, + )) + .expect("should plan the page change") + .head_inserts; + assert!( + written + .iter() + .any(|script| script.contains(DIDOMI_DEFAULT_PREFIX)), + "should write the default path: {written:?}" + ); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the settings script's middleware should be registered"); + for script in &written { + assert!(key.contains(script.as_str()), "{script} is not in {key}"); + } + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. diff --git a/crates/cmp/inmobi/.gitattributes b/crates/cmp/inmobi/.gitattributes new file mode 100644 index 0000000000..fad78ffdb5 --- /dev/null +++ b/crates/cmp/inmobi/.gitattributes @@ -0,0 +1,4 @@ +# The recording is compared byte for byte with the page made from the input +# beside it, so both must arrive with the line endings they were committed +# with on every platform. +src/fixtures/page-change.*.html text eol=lf diff --git a/crates/cmp/inmobi/Cargo.toml b/crates/cmp/inmobi/Cargo.toml new file mode 100644 index 0000000000..27c5725e7d --- /dev/null +++ b/crates/cmp/inmobi/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "trusted-server-cmp-inmobi" +description = "InMobi Choice as a consent management platform module: the prompt's tag, with the IAB TCF and GPP stubs ahead of it, written into a publisher's pages." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +error-stack = { workspace = true } +serde = { workspace = true } +trusted-server-core = { workspace = true } +url = { workspace = true } +validator = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. InMobi has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/cmp/inmobi/src/fixtures/page-change.input.html b/crates/cmp/inmobi/src/fixtures/page-change.input.html new file mode 100644 index 0000000000..960f451276 --- /dev/null +++ b/crates/cmp/inmobi/src/fixtures/page-change.input.html @@ -0,0 +1,12 @@ + + + + +InMobi Choice + + + +An article on the origin + + + diff --git a/crates/cmp/inmobi/src/fixtures/page-change.recorded.html b/crates/cmp/inmobi/src/fixtures/page-change.recorded.html new file mode 100644 index 0000000000..f555f7ba66 --- /dev/null +++ b/crates/cmp/inmobi/src/fixtures/page-change.recorded.html @@ -0,0 +1,40 @@ + + + + +InMobi Choice + + + +An article on the origin + + + diff --git a/crates/cmp/inmobi/src/fixtures/page-change.settings.toml b/crates/cmp/inmobi/src/fixtures/page-change.settings.toml new file mode 100644 index 0000000000..a7cafb39dd --- /dev/null +++ b/crates/cmp/inmobi/src/fixtures/page-change.settings.toml @@ -0,0 +1,9 @@ +[cmp] +module = "inmobi" + +[cmp.inmobi] +script_url = "https://cmp.inmobi.com/choice/EXAMPLEacct00/www.publisher.example/choice.js?tag_version=V3" + +[[fetch]] +media_type = "text/html" +middleware = ["cmp.inmobi"] diff --git a/crates/cmp/inmobi/src/gpp_stub.js b/crates/cmp/inmobi/src/gpp_stub.js new file mode 100644 index 0000000000..3ea7537426 --- /dev/null +++ b/crates/cmp/inmobi/src/gpp_stub.js @@ -0,0 +1,29 @@ +(function(cmpId){ +if(typeof window.__gpp==="function")return; +var apis=["2:tcfeuv2","6:uspv1","7:usnatv1","8:usca","9:usvav1","10:uscov1","11:usutv1","12:usctv1"]; +function ping(){return{gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:apis,cmpId:cmpId,sectionList:[],applicableSections:[-1],gppString:"",parsedSections:{}};} +var stub=function(){var b=arguments;stub.queue=stub.queue||[];stub.events=stub.events||[]; +if(!b.length||(b.length==1&&b[0]=="queue"))return stub.queue; +if(b.length==1&&b[0]=="events")return stub.events; +var cmd=b[0],clb=b.length>1?b[1]:null,par=b.length>2?b[2]:null; +function answer(v,ok){if(typeof clb==="function")clb(v,ok);} +if(cmd==="ping"){answer(ping(),true);} +else if(cmd==="addEventListener"){if(!("lastId" in stub))stub.lastId=0;stub.lastId++;var lnr=stub.lastId; +stub.events.push({id:lnr,callback:clb,parameter:par}); +answer({eventName:"listenerRegistered",listenerId:lnr,data:true,pingData:ping()},true);} +else if(cmd==="removeEventListener"){var ok=false; +for(var i=0;i String { + format!("", TCF_STUB_SCRIPT.trim_end()) +} + +/// The GPP stub as the head carries it, told the CMP id. The page carries the +/// same bytes whichever line ending a checkout gave the file. +fn gpp_stub(cmp_id: u16) -> String { + let script = GPP_STUB_SCRIPT.trim_end().replace("\r\n", "\n"); + format!("") +} + +/// Configuration for the `InMobi` Choice consent prompt. +#[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct InMobiChoiceConfig { + /// The account's Choice loader, which is per publisher and per site. + /// + /// Checked by [`validate_script_url`] rather than only by the type, + /// because this becomes a `", + tcf_stub(), + gpp_stub(cmp_id), + escape_attribute(script_url.trim()) + )), + } + } +} + +impl Middleware for Choice { + fn middleware_id(&self) -> &'static str { + MODULE + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Fetch] + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction { + head_inserts: vec![self.tag.to_string()], + ..MiddlewareAction::pass() + } + } + + /// The whole tag, because the stubs are compiled into this crate and no + /// setting changes when they do. + fn template_key(&self) -> &str { + &self.tag + } +} + +/// Escapes a value for an HTML double quoted attribute. +/// +/// The URL is checked before it reaches here, so this is the second of two +/// checks, because a URL on the right host can still carry a quote in its +/// query that would end the attribute early. +fn escape_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::html_processor::test_support::{ + assert_page_is_recorded, page_settings, + }; + use trusted_server_core::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use trusted_server_core::middleware::test_support::context; + use trusted_server_core::middleware::{MiddlewareChain, PhaseEntries}; + + const REAL: &str = "https://cmp.inmobi.com/choice/EXAMPLEacct00/www.publisher.example/choice.js?tag_version=V3"; + + /// `InMobi`'s registered CMP id. + const CMP_ID: u16 = 10; + + /// A document selecting the prompt, named in a fetch entry. + fn settings_with(url: &str) -> Settings { + page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = {}\n\n\ + [[fetch]]\nmedia_type = \"text/html\"\nmiddleware = [\"cmp.inmobi\"]\n", + serde_json::to_string(url).expect("a string") + )) + } + + /// A document selecting the prompt at [`REAL`], with `rest` written after + /// `script_url`, being more of `[cmp.inmobi]` and then the entries. + fn settings_with_rest(rest: &str) -> Settings { + page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\n{rest}" + )) + } + + /// A `[[fetch]]` entry for `text/html`, covering the pages under `path` + /// where one is given, that names `middleware` in order. + fn html_entry(path: Option<&str>, middleware: &[&str]) -> String { + let path = path + .map(|path| format!("path = \"{path}\"\n")) + .unwrap_or_default(); + format!( + "\n[[fetch]]\nmedia_type = \"text/html\"\n{path}middleware = {}\n", + serde_json::to_string(middleware).expect("a list") + ) + } + + /// Whether a page asked for at `path` gets the prompt from `settings`' + /// entries. + fn gets_the_prompt(settings: &Settings, path: &str) -> bool { + settings + .fetch + .for_response(HTML_MEDIA_TYPE, path) + .iter() + .any(|name| name == MODULE) + } + + /// Checks that `validate` and `register` both refuse `settings`, each + /// with a message that says every one of `reasons`. + fn assert_refused(settings: &Settings, reasons: &[&str]) { + for (call, refusal) in [ + ("validate", validate(settings).err()), + ("register", register(settings).err()), + ] { + let message = refusal + .unwrap_or_else(|| panic!("{call} should refuse the document")) + .current_context() + .to_string(); + for reason in reasons { + assert!( + message.contains(reason), + "{call} should say {reason:?}, and said {message:?}" + ); + } + } + } + + /// The head markup `middleware` write in `phase`, in the order written. + fn head_markup(phase: MiddlewarePhase, middleware: Vec>) -> Vec { + let document_state = IntegrationDocumentState::default(); + MiddlewareChain::new(phase, middleware) + .plan(&context(phase, &document_state)) + .expect("the chain should plan") + .head_inserts + } + + /// The one piece of head markup the prompt writes in `phase`. + fn tag_in(phase: MiddlewarePhase, prompt: Choice) -> String { + let mut markup = head_markup(phase, vec![Arc::new(prompt)]); + assert_eq!( + markup.len(), + 1, + "{phase}: the prompt writes one piece: {markup:?}" + ); + markup.remove(0) + } + + /// A stand-in for a vendor middleware that writes its own tag into the + /// head, such as a tag manager. + #[derive(Debug)] + struct VendorTag; + + impl Middleware for VendorTag { + fn middleware_id(&self) -> &'static str { + "vendor.tag" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &MiddlewarePhase::ALL + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction { + head_inserts: vec![ + "".to_owned(), + ], + ..MiddlewareAction::pass() + } + } + } + + #[test] + fn module_constant_is_the_crate_folder() { + assert_eq!( + MODULE, + trusted_server_core::module_name!(), + "should be named by the folder this crate lives in" + ); + } + + #[test] + fn an_unselected_module_does_not_register() { + let settings = page_settings(""); + assert!( + register(&settings) + .expect("should read a document that selects nothing") + .is_none() + ); + assert!(!validate(&settings).expect("should read a document that selects nothing")); + } + + #[test] + fn the_prompt_runs_in_the_fetch_phase_alone() { + assert_eq!( + Choice::new(REAL, CMP_ID).phases(), + [MiddlewarePhase::Fetch], + "the tag is the same for every reader, so it belongs in the stored page" + ); + } + + #[test] + fn the_prompt_is_offered_as_the_modules_middleware() { + let registration = register(&settings_with(REAL)) + .expect("should parse") + .expect("should register"); + + assert!( + registration + .middleware + .iter() + .any(|middleware| middleware.middleware_id() == MODULE), + "the prompt should be offered for an entry to write" + ); + assert!( + registration.js_disabled, + "the stubs and the Choice script are the whole client side" + ); + } + + /// Selected but named in no entry, the prompt would never be written. + #[test] + fn a_document_naming_the_prompt_in_no_entry_is_refused() { + let settings = page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\n" + )); + + let refused = validate(&settings).expect_err("should refuse a prompt no entry names"); + assert!( + refused + .current_context() + .to_string() + .contains("no [[fetch]] entry for \"text/html\" names `cmp.inmobi`"), + "{refused:?}" + ); + assert!(register(&settings).is_err()); + } + + /// Only an entry for `text/html` writes the prompt into a page, so an + /// entry for another media type naming it does not place it. + #[test] + fn an_entry_for_another_media_type_does_not_place_the_prompt() { + let mut settings = settings_with(REAL); + settings.fetch = PhaseEntries::new(vec![PhaseEntry { + media_type: "application/javascript".to_owned(), + path: None, + middleware: vec![MODULE.to_owned()], + }]); + + let refused = validate(&settings).expect_err("should refuse a prompt no page entry names"); + assert!( + refused + .current_context() + .to_string() + .contains("no [[fetch]] entry for \"text/html\" names `cmp.inmobi`"), + "{refused:?}" + ); + assert!(register(&settings).is_err()); + } + + /// A prompt entry with a path, and no entry for the rest of the site, + /// leaves every other page with no prompt, so it is refused naming the + /// pages it leaves out. + #[test] + fn a_prompt_entry_limited_to_a_path_is_refused_naming_the_pages_left_out() { + let settings = settings_with_rest(&html_entry(Some("/news/"), &[MODULE])); + assert!( + gets_the_prompt(&settings, "/news/today") && !gets_the_prompt(&settings, "/"), + "the entry puts the prompt on the pages under /news/ alone" + ); + + assert_refused( + &settings, + &[ + "cover only the pages under `/news/`", + "`/` among them", + "with no consent prompt", + ], + ); + } + + /// A response takes the first entry that covers it, so an earlier entry + /// for part of the site that does not name the prompt leaves those pages + /// without it, whatever a later entry names. + #[test] + fn an_earlier_entry_without_the_prompt_is_refused_naming_its_pages() { + let settings = settings_with_rest(&format!( + "{}{}", + html_entry(Some("/news/"), &["vendor.tag"]), + html_entry(None, &[MODULE, "vendor.tag"]) + )); + assert!( + !gets_the_prompt(&settings, "/news/today") && gets_the_prompt(&settings, "/"), + "the pages under /news/ get the vendor's tag and no prompt" + ); + + assert_refused( + &settings, + &[ + "[[fetch]] entry 1 covers the pages under `/news/` and does not name \ + `cmp.inmobi`", + "with no consent prompt", + ], + ); + } + + /// Entries that each name the prompt, the last covering every path, put + /// it on every page and are accepted, and so is one entry for `/`. + #[test] + fn entries_that_put_the_prompt_on_every_page_are_accepted() { + for entries in [ + format!( + "{}{}", + html_entry(Some("/news/"), &[MODULE, "vendor.tag"]), + html_entry(None, &[MODULE]) + ), + html_entry(Some("/"), &[MODULE]), + ] { + let settings = settings_with_rest(&entries); + for path in ["/", "/news/today", "/sport/"] { + assert!( + gets_the_prompt(&settings, path), + "{path} should get the prompt from {entries}" + ); + } + assert!( + validate(&settings).expect("should accept the entries"), + "{entries}" + ); + assert!( + register(&settings) + .expect("should accept the entries") + .is_some(), + "{entries}" + ); + } + } + + /// A script URL that should never reach a reader's browser is refused at + /// load and at registration, with a message naming the section and the + /// reason. + #[test] + fn a_script_url_that_should_never_be_served_is_refused_for_its_reason() { + // (case, script URL, the reason the refusal must give) + let cases = [ + ("an empty URL", " ", "is empty"), + ( + "a value that is not a URL", + "cmp.inmobi.com/choice.js", + "is not a URL", + ), + ( + "plain http, which whoever carries the page can replace", + "http://cmp.inmobi.com/choice/a/b/choice.js", + "is \"http\", not https", + ), + ( + "a host that is not InMobi's", + "https://evil.example.com/choice.js", + "points at \"evil.example.com\", which is not an InMobi Choice host", + ), + ( + "a host that merely contains an allowed name", + "https://cmp.inmobi.com.evil.example/choice.js", + "points at \"cmp.inmobi.com.evil.example\", which is not an InMobi", + ), + ( + "an allowed name before an `@`, which makes it a user name", + "https://cmp.inmobi.com@evil.example/choice.js", + "points at \"evil.example\", which is not an InMobi", + ), + ( + "a user name and password on an allowed host", + "https://user:secret@cmp.inmobi.com/choice/a/b/choice.js", + "carries a user name or password", + ), + ( + "a user name alone on an allowed host", + "https://user@cmp.inmobi.com/choice/a/b/choice.js", + "carries a user name or password", + ), + ( + "a password alone on an allowed host", + "https://:secret@cmp.inmobi.com/choice/a/b/choice.js", + "carries a user name or password", + ), + ]; + for (case, url, reason) in cases { + let settings = settings_with(url); + for (call, refusal) in [ + ("validate", validate(&settings).err()), + ("register", register(&settings).err()), + ] { + let message = refusal + .unwrap_or_else(|| panic!("{case}: {call} should refuse {url:?}")) + .current_context() + .to_string(); + assert!( + message.contains("`script_url` in [cmp.inmobi]") && message.contains(reason), + "{case}: {call} should say {reason:?}, and said {message:?}" + ); + } + } + } + + #[test] + fn the_cmp_id_defaults_to_inmobis_registered_number() { + let config = settings_with(REAL) + .module_config::(MODULE) + .expect("should read the table") + .expect("should select the module"); + assert_eq!(config.cmp_id, CMP_ID); + } + + /// The CMP id in `[cmp.inmobi]` is the one the GPP stub is told. + #[test] + fn the_configured_cmp_id_reaches_the_gpp_stub() { + let settings = page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\ncmp_id = 42\n\n\ + [[fetch]]\nmedia_type = \"text/html\"\nmiddleware = [\"cmp.inmobi\"]\n" + )); + let registration = register(&settings) + .expect("should parse") + .expect("should register"); + + let markup = head_markup(MiddlewarePhase::Fetch, registration.middleware).join(""); + + assert!(markup.contains("})(42);"), "{markup}"); + assert!(!markup.contains("})(10);"), "{markup}"); + } + + /// A CMP id is a whole number the IAB assigned, which a TC string holds + /// in 12 bits, so any other value is refused when the settings load, with + /// a message naming the setting and never its value. + #[test] + fn a_cmp_id_outside_1_to_4095_is_refused_naming_the_setting() { + // The test settings name the template's placeholder origin, which the + // load refuses before any module's own check, so a real one stands in. + let with_cmp_id = |value: &str| { + let mut settings = settings_with_rest(&format!( + "cmp_id = {value}\n{}", + html_entry(None, &[MODULE]) + )); + settings.publisher.origin_url = "https://origin.publisher.example".to_owned(); + settings + }; + let load = |settings: &Settings| { + trusted_server_core::config::validate_settings_for_runtime_with( + settings, + &[super::builder()], + ) + }; + + // (case, the value as written) + let cases = [ + ("zero, which a stub reports before it has an id", "0"), + ("past the 12 bits a TC string holds", "4096"), + ("past what the field could hold", "70000"), + ("below zero", "-1"), + ("a string", "\"10\""), + ("a fraction", "10.5"), + ]; + for (case, value) in cases { + let settings = with_cmp_id(value); + for (call, refusal) in [ + ("loading the settings", load(&settings).err()), + ("validate", validate(&settings).err()), + ("register", register(&settings).err()), + ] { + let message = refusal + .unwrap_or_else(|| panic!("{case}: {call} should refuse cmp_id = {value}")) + .current_context() + .to_string(); + assert!( + message.contains("[cmp.inmobi]") && message.contains("`cmp_id`"), + "{case}: {call} should name the table and the setting, and said {message:?}" + ); + assert!( + !message.contains(value), + "{case}: {call} should not quote the value, and said {message:?}" + ); + } + } + + for (value, id) in [("1", 1), ("4095", 4095)] { + let settings = with_cmp_id(value); + load(&settings).expect("should load a CMP id in range"); + let config = settings + .module_config::(MODULE) + .expect("should read the table") + .expect("should select the module"); + assert_eq!(config.cmp_id, id); + } + } + + #[test] + fn an_unknown_field_is_refused_rather_than_ignored() { + let settings = page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\ntypo = true\n" + )); + assert!( + settings + .module_config::(MODULE) + .is_err() + ); + } + + /// The tag is written once, deferred and carrying the account's URL. + #[test] + fn the_tag_is_deferred_and_carries_the_url() { + let tag = tag_in(MiddlewarePhase::Fetch, Choice::new(REAL, CMP_ID)); + + let loader = ""; + assert_eq!(tag.matches(loader).count(), 1, "{tag}"); + assert!(tag.ends_with(loader), "the loader comes last: {tag}"); + } + + /// The prompt's loader calls `__tcfapi` and `__gpp` as soon as it runs, + /// so each stub has to be ahead of the loader, once, the TCF stub first. + #[test] + fn the_stubs_go_ahead_of_the_loader_once_each_with_the_tcf_stub_first() { + let tag = tag_in(MiddlewarePhase::Fetch, Choice::new(REAL, 42)); + + let tcf = tcf_stub(); + let gpp = gpp_stub(42); + assert!(gpp.ends_with("})(42);"), "{gpp}"); + assert_eq!(tag.matches(&tcf).count(), 1, "{tag}"); + assert_eq!(tag.matches(&gpp).count(), 1, "{tag}"); + let position = |needle: &str| { + tag.find(needle) + .unwrap_or_else(|| panic!("{needle} belongs in the tag: {tag}")) + }; + assert!( + position(&tcf) < position(&gpp) && position(&gpp) < position("cmp.inmobi.com"), + "the order is TCF stub, GPP stub, loader: {tag}" + ); + } + + /// The stubs are compiled into this crate and no setting changes when + /// they do, so they have to be in the text the registered middleware + /// keys a stored page by, or a page stored before a change to a stub + /// keeps the old stub until it expires. + #[test] + fn the_stubs_are_in_what_keys_a_stored_page() { + let settings = settings_with(REAL); + let registry = IntegrationRegistry::with_registrations(&settings, &[super::builder()]) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the prompt's middleware should be registered"); + assert!(key.contains(&tcf_stub()), "{key}"); + assert!(key.contains(&gpp_stub(CMP_ID)), "{key}"); + } + + #[test] + fn ampersands_and_quotes_in_the_url_are_escaped() { + let tag = tag_in( + MiddlewarePhase::Fetch, + Choice::new( + "https://cmp.inmobi.com/choice/abc/site/choice.js?a=1&b=\"2\"", + CMP_ID, + ), + ); + + assert!(tag.contains("a=1&b="2""), "{tag}"); + } + + /// The entry's order is the head's order, which is why the prompt must be + /// named before any vendor's tag. + #[test] + fn a_vendor_tag_named_after_the_prompt_is_written_after_it() { + let prompt_first = head_markup( + MiddlewarePhase::Fetch, + vec![Arc::new(Choice::new(REAL, CMP_ID)), Arc::new(VendorTag)], + ) + .join(""); + let vendor_first = head_markup( + MiddlewarePhase::Fetch, + vec![Arc::new(VendorTag), Arc::new(Choice::new(REAL, CMP_ID))], + ) + .join(""); + + let position = |page: &str, needle: &str| { + page.find(needle) + .unwrap_or_else(|| panic!("{needle} should be written: {page}")) + }; + assert!( + position(&prompt_first, "cmp.inmobi.com") < position(&prompt_first, "vendor.example"), + "named first, the prompt should load first: {prompt_first}" + ); + assert!( + position(&vendor_first, "vendor.example") < position(&vendor_first, "cmp.inmobi.com"), + "named second, the prompt loads after the vendor's tag, so the order \ + in the entry is what keeps it first: {vendor_first}" + ); + } + + /// The page a reader receives with this module running, kept as a file + /// so that changing how the page change is made can be shown to leave + /// the page as it was. + #[test] + fn the_page_a_reader_receives_is_the_recorded_one() { + assert_page_is_recorded( + include_str!("fixtures/page-change.settings.toml"), + &[super::builder()], + include_str!("fixtures/page-change.input.html"), + include_str!("fixtures/page-change.recorded.html"), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/fixtures/page-change.recorded.html" + ), + ); + } +} diff --git a/crates/cmp/inmobi/src/tcf_stub.js b/crates/cmp/inmobi/src/tcf_stub.js new file mode 100644 index 0000000000..259227abec --- /dev/null +++ b/crates/cmp/inmobi/src/tcf_stub.js @@ -0,0 +1 @@ +(function(){if(typeof window.__tcfapi==="function")return;var n="__tcfapiLocator",w=window;while(w){try{if(w.frames[n])return;}catch(x){}if(w===window.top)break;w=w.parent;}var q=[],gdprApplies;(function add(){if(window.frames[n])return;if(document.body){var f=document.createElement("iframe");f.style.display="none";f.name=n;document.body.appendChild(f);}else{setTimeout(add,5);}})();window.__tcfapi=function(){var a=Array.prototype.slice.call(arguments);if(!a.length)return q;if(a[0]==="setGdprApplies"){if(a.length>3&&parseInt(a[1],10)===2&&typeof a[3]==="boolean"){gdprApplies=a[3];if(typeof a[2]==="function")a[2]("set",true);}}else if(a[0]==="ping"){if(typeof a[2]==="function")a[2]({gdprApplies:gdprApplies,cmpLoaded:false,cmpStatus:"stub",apiVersion:"2.2"});}else{q.push(a);}};window.addEventListener("message",function(e){var s=typeof e.data==="string",d={};try{d=s?JSON.parse(e.data):e.data;}catch(x){}var c=d&&d.__tcfapiCall;if(c){window.__tcfapi(c.command,c.version,function(r,ok){var m={__tcfapiReturn:{returnValue:r,success:ok,callId:c.callId}};if(e.source)e.source.postMessage(s?JSON.stringify(m):m,"*");},c.parameter);}},false);})(); diff --git a/crates/cmp/inmobi/tests/gpp_stub.test.mjs b/crates/cmp/inmobi/tests/gpp_stub.test.mjs new file mode 100644 index 0000000000..e9cdb7104e --- /dev/null +++ b/crates/cmp/inmobi/tests/gpp_stub.test.mjs @@ -0,0 +1,224 @@ +// Runs InMobi's GPP stub the InMobi Choice module writes into a page, in a +// small stand-in for the browser, and checks what a page and its frames see. +// Run with `node --test crates/cmp/inmobi/tests/gpp_stub.test.mjs`. + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const STUB = readFileSync(new URL('../src/gpp_stub.js', import.meta.url), 'utf8'); + +// The sections InMobi's own tag says its prompt supports. +const SUPPORTED_APIS = [ + '2:tcfeuv2', + '6:uspv1', + '7:usnatv1', + '8:usca', + '9:usvav1', + '10:uscov1', + '11:usutv1', + '12:usctv1', +]; + +// What ping answers while the stub is in place, for a CMP id. +function stubPing(cmpId) { + return { + gppVersion: '1.1', + cmpStatus: 'stub', + cmpDisplayStatus: 'hidden', + signalStatus: 'not ready', + supportedAPIs: SUPPORTED_APIS, + cmpId, + sectionList: [], + applicableSections: [-1], + gppString: '', + parsedSections: {}, + }; +} + +// A window and document holding only what the stub touches, with the stub +// told `cmpId` as the middleware tells it. `body` is false for a page whose +// body has not been parsed yet. +function browser({ cmpId = 10, body = true, gpp } = {}) { + const listeners = []; + const appended = []; + const timers = []; + const window = { + frames: {}, + addEventListener(type, listener) { + listeners.push({ type, listener }); + }, + }; + if (gpp) { + window.__gpp = gpp; + } + const document = { + body: body + ? { + appendChild(frame) { + appended.push(frame); + window.frames[frame.name] = frame; + }, + } + : null, + createElement(tag) { + return { tag, style: {} }; + }, + }; + const setTimeout = (callback, delay, ...args) => timers.push({ callback, delay, args }); + new Function('window', 'document', 'setTimeout', `${STUB.trimEnd()}(${cmpId});`)( + window, + document, + setTimeout + ); + return { window, document, listeners, appended, timers }; +} + +// Posts `data` to the window from a frame, returning what the frame was sent. +function post(page, data) { + const replies = []; + const source = { postMessage: (message, origin) => replies.push({ message, origin }) }; + for (const { type, listener } of page.listeners) { + if (type === 'message') { + listener({ data, source }); + } + } + return replies; +} + +test('ping answers at once with the CMP id the stub is told', () => { + for (const cmpId of [10, 42]) { + const page = browser({ cmpId }); + const answers = []; + + page.window.__gpp('ping', (data, success) => answers.push({ data, success })); + + assert.deepEqual(answers, [{ data: stubPing(cmpId), success: true }]); + assert.deepEqual(page.window.__gpp('queue'), [], 'ping is answered, not queued'); + } +}); + +test('other calls are queued and handed over', () => { + const page = browser(); + const callback = () => assert.fail('a queued call is answered by the prompt'); + + page.window.__gpp('getGPPData', callback); + page.window.__gpp('signalStatus', callback, 'parameter', '1.1'); + + const queued = [ + ['getGPPData', callback], + ['signalStatus', callback, 'parameter', '1.1'], + ]; + assert.deepEqual(page.window.__gpp('queue'), queued); + assert.deepEqual(page.window.__gpp(), queued); + assert.equal(page.window.__gpp.queue, page.window.__gpp('queue'), 'kept on the stub'); +}); + +test('a listener is registered and removed at once, with the ping data', () => { + const page = browser({ cmpId: 42 }); + const answers = []; + const answer = (data, success) => answers.push({ data, success }); + + page.window.__gpp('addEventListener', answer); + assert.deepEqual(answers, [ + { + data: { eventName: 'listenerRegistered', listenerId: 1, data: true, pingData: stubPing(42) }, + success: true, + }, + ]); + assert.deepEqual(page.window.__gpp('events'), [{ id: 1, callback: answer, parameter: null }]); + + page.window.__gpp('removeEventListener', answer, 1); + page.window.__gpp('removeEventListener', answer, 7); + assert.deepEqual(answers.slice(1), [ + { + data: { eventName: 'listenerRemoved', listenerId: 1, data: true, pingData: stubPing(42) }, + success: true, + }, + { + data: { eventName: 'listenerRemoved', listenerId: 7, data: false, pingData: stubPing(42) }, + success: true, + }, + ]); + assert.deepEqual(page.window.__gpp('events'), []); + assert.deepEqual(page.window.__gpp('queue'), [], 'listeners are not queued'); +}); + +test('hasSection, getSection and getField answer at once', () => { + const page = browser(); + const answers = []; + + for (const command of ['hasSection', 'getSection', 'getField']) { + page.window.__gpp( + command, + (data, success) => answers.push([command, data, success]), + 'tcfeuv2' + ); + } + + assert.deepEqual(answers, [ + ['hasSection', false, true], + ['getSection', null, true], + ['getField', null, true], + ]); + assert.deepEqual(page.window.__gpp('queue'), []); +}); + +test('a frame that posts a call is answered with the call id', () => { + const page = browser({ cmpId: 42 }); + + const objectReplies = post(page, { __gppCall: { command: 'ping', callId: 'object-call' } }); + assert.deepEqual(objectReplies, [ + { + message: { + __gppReturn: { returnValue: stubPing(42), success: true, callId: 'object-call' }, + }, + origin: '*', + }, + ]); + + // A frame that posts a string is answered with a string. + const stringReplies = post( + page, + JSON.stringify({ __gppCall: { command: 'ping', callId: 'string-call' } }) + ); + assert.equal(stringReplies.length, 1); + assert.equal(typeof stringReplies[0].message, 'string'); + const reply = JSON.parse(stringReplies[0].message).__gppReturn; + assert.equal(reply.callId, 'string-call'); + assert.equal(reply.returnValue.cmpId, 42); + + // Anything else posted to the page is left alone. + assert.deepEqual(post(page, 'not json'), []); + assert.deepEqual(post(page, { other: true }), []); + assert.deepEqual(post(page, { __gppCall: 'not a call' }), []); +}); + +test('creates the hidden __gppLocator frame that frames find the stub by', () => { + const page = browser(); + + assert.equal(page.appended.length, 1); + assert.equal(page.appended[0].tag, 'iframe'); + assert.equal(page.appended[0].name, '__gppLocator'); + assert.equal(page.appended[0].style.cssText, 'display:none'); + assert.equal(page.timers.length, 0); +}); + +test('before the body exists the locator frame waits for it', () => { + const page = browser({ body: false }); + + assert.equal(page.appended.length, 0); + assert.equal(page.timers.length, 1); + assert.equal(page.timers[0].delay, 10); + assert.deepEqual(page.timers[0].args, ['__gppLocator']); +}); + +test('a page that already has a __gpp keeps it', () => { + const existing = () => 'own'; + const page = browser({ gpp: existing }); + + assert.equal(page.window.__gpp, existing); + assert.equal(page.window.__gpp_stub, undefined, 'nothing is defined'); + assert.equal(page.listeners.length, 0, 'no relay is added'); + assert.equal(page.appended.length, 0, 'no locator frame is added'); +}); diff --git a/crates/cmp/inmobi/tests/tcf_stub.test.mjs b/crates/cmp/inmobi/tests/tcf_stub.test.mjs new file mode 100644 index 0000000000..0d94bfe7e1 --- /dev/null +++ b/crates/cmp/inmobi/tests/tcf_stub.test.mjs @@ -0,0 +1,218 @@ +// Runs the IAB TCF v2 stub the InMobi Choice module writes into a page, in a +// small stand-in for the browser, and checks what a page and its frames see. +// Run with `node --test crates/cmp/inmobi/tests/tcf_stub.test.mjs`. + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const STUB = readFileSync(new URL('../src/tcf_stub.js', import.meta.url), 'utf8'); + +// A window and document holding only what the stub touches. `body` is false +// for a page whose body has not been parsed yet. `parent` and `top` are the +// windows above a page served inside a frame. +function browser({ body = true, tcfapi, parent, top } = {}) { + const listeners = []; + const appended = []; + const timers = []; + const window = { + frames: {}, + addEventListener(type, listener) { + listeners.push({ type, listener }); + }, + }; + if (tcfapi) { + window.__tcfapi = tcfapi; + } + if (parent) { + window.parent = parent; + window.top = top ?? parent; + } + const document = { + body: body + ? { + appendChild(frame) { + appended.push(frame); + window.frames[frame.name] = frame; + }, + } + : null, + createElement(tag) { + return { tag, style: {} }; + }, + }; + const setTimeout = (callback, delay) => timers.push({ callback, delay }); + new Function('window', 'document', 'setTimeout', STUB)(window, document, setTimeout); + return { window, document, listeners, appended, timers }; +} + +// Posts `data` to the window from a frame, returning what the frame was sent. +function post(page, data) { + const replies = []; + const source = { postMessage: (message, origin) => replies.push({ message, origin }) }; + for (const { type, listener } of page.listeners) { + if (type === 'message') { + listener({ data, source }); + } + } + return replies; +} + +test('calls made before the prompt arrives are queued and handed over', () => { + const page = browser(); + const callback = () => {}; + + page.window.__tcfapi('getTCData', 2, callback); + page.window.__tcfapi('addEventListener', 2, callback); + + assert.deepEqual(page.window.__tcfapi(), [ + ['getTCData', 2, callback], + ['addEventListener', 2, callback], + ]); +}); + +test('ping answers as a stub that has not loaded', () => { + const page = browser(); + let answer; + + page.window.__tcfapi('ping', 2, (value) => { + answer = value; + }); + + assert.deepEqual(answer, { + gdprApplies: undefined, + cmpLoaded: false, + cmpStatus: 'stub', + apiVersion: '2.2', + }); + assert.deepEqual(page.window.__tcfapi(), [], 'ping is answered, not queued'); +}); + +test('setGdprApplies sets what ping reports', () => { + const page = browser(); + const set = []; + + page.window.__tcfapi('setGdprApplies', 2, (...args) => set.push(args), true); + let answer; + page.window.__tcfapi('ping', 2, (value) => { + answer = value; + }); + + assert.deepEqual(set, [['set', true]]); + assert.equal(answer.gdprApplies, true); + + // Anything but version 2 and a boolean is ignored. + page.window.__tcfapi('setGdprApplies', 1, () => set.push('v1'), false); + page.window.__tcfapi('setGdprApplies', 2, () => set.push('text'), 'false'); + page.window.__tcfapi('ping', 2, (value) => { + answer = value; + }); + assert.deepEqual(set, [['set', true]]); + assert.equal(answer.gdprApplies, true); +}); + +test('a frame that posts a call is answered with the call id', () => { + const page = browser(); + + const objectReplies = post(page, { + __tcfapiCall: { command: 'ping', version: 2, callId: 'object-call' }, + }); + assert.deepEqual(objectReplies, [ + { + message: { + __tcfapiReturn: { + returnValue: { + gdprApplies: undefined, + cmpLoaded: false, + cmpStatus: 'stub', + apiVersion: '2.2', + }, + success: undefined, + callId: 'object-call', + }, + }, + origin: '*', + }, + ]); + + // A frame that posts a string is answered with a string. + const stringReplies = post( + page, + JSON.stringify({ __tcfapiCall: { command: 'ping', version: 2, callId: 'string-call' } }) + ); + assert.equal(stringReplies.length, 1); + assert.equal(typeof stringReplies[0].message, 'string'); + assert.equal(JSON.parse(stringReplies[0].message).__tcfapiReturn.callId, 'string-call'); + + // Anything else posted to the page is left alone. + assert.deepEqual(post(page, 'not json'), []); + assert.deepEqual(post(page, { other: true }), []); +}); + +test('creates the hidden __tcfapiLocator frame that frames find the stub by', () => { + const page = browser(); + + assert.equal(page.appended.length, 1); + assert.equal(page.appended[0].tag, 'iframe'); + assert.equal(page.appended[0].name, '__tcfapiLocator'); + assert.equal(page.appended[0].style.display, 'none'); + assert.equal(page.timers.length, 0); +}); + +test('before the body exists the locator frame waits for it', () => { + const page = browser({ body: false }); + + assert.equal(page.appended.length, 0); + assert.equal(page.timers.length, 1); + assert.equal(page.timers[0].delay, 5); +}); + +test('a page that already has a __tcfapi keeps it', () => { + const existing = () => 'own'; + const page = browser({ tcfapi: existing }); + + assert.equal(page.window.__tcfapi, existing); + assert.equal(page.listeners.length, 0, 'no relay is added'); + assert.equal(page.appended.length, 0, 'no locator frame is added'); +}); + +// A window above the page, with the frames it holds by name. +function ancestor(frames = {}) { + const window = { frames }; + window.parent = window; + window.top = window; + return window; +} + +test('a page inside a frame of a page that already has a CMP gets no second stub', () => { + const top = ancestor({ __tcfapiLocator: {} }); + const middle = { frames: {}, parent: top, top }; + const page = browser({ parent: middle, top }); + + assert.equal(page.window.__tcfapi, undefined, 'no __tcfapi is defined'); + assert.equal(page.listeners.length, 0, 'no relay is added'); + assert.equal(page.appended.length, 0, 'no locator frame is added'); + assert.equal(page.timers.length, 0, 'no locator frame is waited for'); +}); + +test('an ancestor that refuses to be read is passed over on the way up', () => { + const top = ancestor({ __tcfapiLocator: {} }); + const refusing = { + get frames() { + throw new Error('cross origin'); + }, + parent: top, + top, + }; + const page = browser({ parent: refusing, top }); + + assert.equal(page.window.__tcfapi, undefined, 'the CMP above it is found'); +}); + +test('a page inside a frame with no CMP above it gets the stub', () => { + const top = ancestor(); + const page = browser({ parent: top }); + + assert.equal(typeof page.window.__tcfapi, 'function'); + assert.equal(page.appended.length, 1, 'the locator frame is added'); +}); diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index 8b0ea6c8eb..2f32250e76 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -825,7 +825,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(SOURCEPOINT_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration))) + .with_middleware(Arc::new(PageChange::new(integration))) .build(), )) } @@ -1095,7 +1095,21 @@ impl IntegrationProxy for SourcepointIntegration { /// [`MODULE`] for. It writes the browser module's settings into the head and, /// when `rewrite_sdk` is set, the trap on `window._sp_`, and points the /// CDN's addresses at the first-party path. -struct PageChange(Arc); +struct PageChange { + integration: Arc, + /// The head scripts as written, then [`SOURCEPOINT_CDN_PREFIX`]. + template_key: String, +} + +impl PageChange { + fn new(integration: Arc) -> Self { + let template_key = integration.head_markup().concat() + SOURCEPOINT_CDN_PREFIX; + Self { + integration, + template_key, + } + } +} impl Middleware for PageChange { fn middleware_id(&self) -> &'static str { @@ -1108,17 +1122,24 @@ impl Middleware for PageChange { fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { let mut action = MiddlewareAction { - head_inserts: self.0.head_markup(), + head_inserts: self.integration.head_markup(), ..MiddlewareAction::pass() }; - if self.0.config.rewrite_sdk { - let integration = Arc::clone(&self.0); + if self.integration.config.rewrite_sdk { + let integration = Arc::clone(&self.integration); let decide: Rc = Rc::new(move |matched| integration.rewrite_sdk_address(matched.value)); action.element_handlers = AttributeRewrite::each(&["src", "href"], &decide); } action } + + /// The head scripts and the first-party path, because the trap on + /// `window._sp_` and the path are compiled into this crate and no setting + /// changes when they do. + fn template_key(&self) -> &str { + &self.template_key + } } impl SourcepointIntegration { @@ -2323,7 +2344,7 @@ mod tests { &document_state, ); - let action = PageChange(integration).create(&context); + let action = PageChange::new(integration).create(&context); assert!( action.element_handlers.is_empty(), @@ -3078,6 +3099,50 @@ mod tests { ); } + /// The trap on `window._sp_` and the first-party path are compiled into + /// this crate and no setting changes when they do, so what the registered + /// middleware writes into the head, and the path, have to be in the text + /// it keys a stored page by, or a page stored before a change keeps the + /// old trap until it expires. + #[test] + fn the_trap_and_the_path_are_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + let document_state = trusted_server_core::integrations::IntegrationDocumentState::default(); + let written = registry + .middleware_chain( + &settings.fetch, + MiddlewarePhase::Fetch, + trusted_server_core::middleware::HTML_MEDIA_TYPE, + "/", + ) + .plan(&trusted_server_core::middleware::test_support::context( + MiddlewarePhase::Fetch, + &document_state, + )) + .expect("should plan the page change") + .head_inserts; + assert!( + written + .iter() + .any(|script| script.contains("Object.defineProperty(window,\"_sp_\"")), + "should write the trap: {written:?}" + ); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + for script in &written { + assert!(key.contains(script.as_str()), "{script} is not in {key}"); + } + assert!(key.contains(SOURCEPOINT_CDN_PREFIX), "{key}"); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. diff --git a/crates/identity/lockr/src/lib.rs b/crates/identity/lockr/src/lib.rs index 8f4269c219..b04265eb22 100644 --- a/crates/identity/lockr/src/lib.rs +++ b/crates/identity/lockr/src/lib.rs @@ -423,6 +423,9 @@ impl IntegrationProxy for LockrIntegration { } } +/// The first-party path a page loads the SDK from. +const SDK_PATH: &str = "/integrations/lockr/sdk"; + impl LockrIntegration { /// What becomes of a `src` or an `href`, which is pointed at the /// first-party endpoint when it is the SDK's address. @@ -433,7 +436,7 @@ impl LockrIntegration { // so on pages that set an external base URL these resolve against // that base rather than the address-bar origin — an accepted // tradeoff, matching GTM/Didomi/Testlight which are also relative. - let replacement = "/integrations/lockr/sdk".to_string(); + let replacement = SDK_PATH.to_string(); log::debug!("Rewriting Lockr SDK URL to {}", replacement); AttributeRewriteAction::Replace(replacement) } else { @@ -467,6 +470,12 @@ impl Middleware for SdkAddress { ..MiddlewareAction::pass() } } + + /// The SDK's path, because it is compiled into this crate and no setting + /// changes when it does. + fn template_key(&self) -> &str { + SDK_PATH + } } fn default_api_endpoint() -> String { @@ -804,6 +813,34 @@ mod tests { ); } + /// The SDK's path is compiled into this crate and no setting changes when + /// it does, so it has to be the text the registered middleware keys a + /// stored page by, or a page stored before a change keeps loading the SDK + /// from the old path until it expires. + #[test] + fn the_sdk_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the SDK's middleware should be registered"); + assert_eq!(*key, SDK_PATH); + assert!( + include_str!("fixtures/page-change.recorded.html") + .contains(&format!("src=\"{SDK_PATH}\"")), + "the recorded page should load the SDK from the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index 023c0449ae..b15c9e74bb 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -53,6 +53,10 @@ use trusted_server_core::settings::{IntegrationConfig, Settings}; const GTM_INTEGRATION_ID: &str = "google_tag_manager"; +/// The first-party path Google's tag addresses are pointed under. +static FIRST_PARTY_PREFIX: LazyLock = + LazyLock::new(|| format!("/integrations/{GTM_INTEGRATION_ID}")); + /// The name this module is selected by, in `[tag]`. pub const MODULE: &str = "tag.google-tag-manager"; @@ -439,7 +443,7 @@ impl GoogleTagManagerIntegration { /// all variants (https, protocol-relative) of `googletagmanager.com`, /// `google-analytics.com` and `analytics.google.com`. fn rewrite_gtm_urls(content: &str) -> String { - let first_party = format!("/integrations/{GTM_INTEGRATION_ID}"); + let first_party = FIRST_PARTY_PREFIX.as_str(); // An attribute value is the whole URL on its own. if let Some(captures) = GTM_WHOLE_URL_PATTERN.captures(content) { @@ -1064,6 +1068,12 @@ impl Middleware for GoogleTagManagerIntegration { ..MiddlewareAction::pass() } } + + /// The path Google's tag addresses are pointed under, because it is + /// compiled into this crate and no setting changes when it does. + fn template_key(&self) -> &str { + FIRST_PARTY_PREFIX.as_str() + } } /// Rewrites Google's tag addresses in the text of inline scripts. @@ -3964,6 +3974,36 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= ); } + /// The path Google's tag addresses are pointed under is compiled into + /// this crate and no setting changes when it does, so it has to be the + /// text the registered middleware keys a stored page by, or a page stored + /// before a change keeps the old addresses until it expires. + #[test] + fn the_first_party_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + assert_eq!(*key, FIRST_PARTY_PREFIX.as_str()); + let recorded = include_str!("fixtures/page-change.recorded.html"); + let prefix = FIRST_PARTY_PREFIX.as_str(); + assert!( + recorded.contains(&format!("src=\"{prefix}/gtag/js?id=G-TEST1234\"")), + "the recorded page should point an attribute under the path the key holds" + ); + assert!( + recorded.contains(&format!("'{prefix}/gtm.js?id='")), + "the recorded page should point an inline snippet under the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 0e65a0648c..7b564c537d 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -200,6 +200,18 @@ pub const SEAM_PROBE_SCRIPT_WORD: &str = "SEAM_PROBE_COUNTRY"; /// that carries `data-seam-probe`. pub struct SeamProbeMiddleware { country: String, + /// The head marker as written, then [`SEAM_PROBE_REPORT_PATH`]. + template_key: String, +} + +impl SeamProbeMiddleware { + fn new(country: String) -> Self { + let template_key = seam_probe_head_marker(&country) + SEAM_PROBE_REPORT_PATH; + Self { + country, + template_key, + } + } } impl Middleware for SeamProbeMiddleware { @@ -226,6 +238,12 @@ impl Middleware for SeamProbeMiddleware { ..MiddlewareAction::pass() } } + + /// The head marker and the report path, because their text is compiled + /// into this crate and no setting changes when it does. + fn template_key(&self) -> &str { + &self.template_key + } } /// The name an entry gives the probe's serve middleware, which is the @@ -727,9 +745,7 @@ pub fn register( source: PROBE_JS, sha256: PROBE_JS_SHA256, }) - .with_middleware(Arc::new(SeamProbeMiddleware { - country: config.country.clone(), - })) + .with_middleware(Arc::new(SeamProbeMiddleware::new(config.country.clone()))) .with_middleware(Arc::new(SeamProbeReaderMiddleware)); if config.declares_geo { registration = registration @@ -1200,6 +1216,26 @@ mod tests { ); } + /// The head marker's markup and the report path are compiled into this + /// crate and no setting changes when they do, so they have to be in the + /// text the registered fetch middleware keys a stored page by. + #[test] + fn the_marker_and_the_report_path_are_in_what_keys_a_stored_page() { + use trusted_server_core::integrations::IntegrationRegistry; + + let settings = settings_with_probe("country = \"NZ\""); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build a registry with the probe registered"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == module_name()) + .expect("the probe's fetch middleware should be registered"); + assert!(key.contains(&seam_probe_head_marker("NZ")), "{key}"); + assert!(key.contains(SEAM_PROBE_REPORT_PATH), "{key}"); + } + #[tokio::test] async fn geo_module_resolves_the_configured_country() { let module = SeamProbeGeo::new("ZZ".to_string()); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 7002197284..fc03657531 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1917,6 +1917,17 @@ impl IntegrationRegistry { .collect() } + /// Each middleware's name with the text it keys a stored page by, in + /// registration order. See [`Middleware::template_key`]. + #[must_use] + pub fn middleware_template_keys(&self) -> Vec<(&'static str, &str)> { + self.inner + .middleware + .iter() + .map(|(_, middleware)| (middleware.middleware_id(), middleware.template_key())) + .collect() + } + /// The middleware the first entry covering a response selects, in the /// order they run, for a response of `media_type` to a request for `path`. /// A response no entry covers gives an empty chain. diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index ccae6c1ae9..e4e270b24b 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -333,6 +333,15 @@ pub trait Middleware: Send + Sync { /// Decides what to do with one document. fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction; + + /// Text a stored page is keyed by, being what this middleware writes + /// that no setting decides, such as markup compiled into its crate. A + /// change to it stores pages again, where otherwise a page stored before + /// the change keeps the old markup until it expires. Empty when the + /// settings decide everything the middleware writes. + fn template_key(&self) -> &str { + "" + } } /// One entry of `[[fetch]]` or `[[serve]]`, being which responses it covers diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6763568530..e5228df42b 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2237,6 +2237,10 @@ fn assemble_if_shared( /// serve. That is safe because a module this deployment never serves appears in no bundle /// hash and no injected URL, so it cannot change what a template renders. /// +/// Each registered middleware's [`template_key`](crate::middleware::Middleware::template_key) +/// covers what it writes that no setting decides, such as markup compiled into a module's +/// crate, which neither the build digest nor the settings see. +/// /// # Panics /// /// Does not panic: serializing the already-deserialized typed settings to a JSON value is @@ -2254,6 +2258,11 @@ fn template_fingerprint_with_build_digest( hasher.update(build_digest.as_bytes()); hasher .update(crate::tsjs_bundle::compose_hash(&integration_registry.js_parts_all()).as_bytes()); + // Each part is hashed alone, so no text can move where the next begins. + for (name, key) in integration_registry.middleware_template_keys() { + hasher.update(sha2::Sha256::digest(name.as_bytes())); + hasher.update(sha2::Sha256::digest(key.as_bytes())); + } // EdgeZero's canonical form sorts object keys itself, so independently deserialized // HashMaps hash identically even when a dependency enables `serde_json/preserve_order`. // Array order is preserved: set-valued settings must serialize deterministically @@ -10252,6 +10261,84 @@ mod tests { ); } + /// Head markup a module's crate compiles in, before a fix to it. + const WRITTEN_BEFORE: &str = ""; + + /// The same markup after the fix. + const WRITTEN_AFTER: &str = ""; + + /// A fetch middleware that writes markup compiled into its crate, + /// which no setting decides. + struct WritesCompiledMarkup(&'static str); + + impl crate::middleware::Middleware for WritesCompiledMarkup { + fn middleware_id(&self) -> &'static str { + "testing.probe" + } + + fn phases(&self) -> &[crate::middleware::MiddlewarePhase] { + &[crate::middleware::MiddlewarePhase::Fetch] + } + + fn create( + &self, + _context: &crate::middleware::MiddlewareContext<'_>, + ) -> crate::middleware::MiddlewareAction { + crate::middleware::MiddlewareAction { + head_inserts: vec![self.0.to_owned()], + ..crate::middleware::MiddlewareAction::pass() + } + } + + fn template_key(&self) -> &str { + self.0 + } + } + + /// Registration of a middleware writing [`WRITTEN_BEFORE`]. + fn writing_before( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .without_js() + .with_middleware(std::sync::Arc::new(WritesCompiledMarkup(WRITTEN_BEFORE))) + .build(), + )) + } + + /// Registration of a middleware writing [`WRITTEN_AFTER`]. + fn writing_after( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .without_js() + .with_middleware(std::sync::Arc::new(WritesCompiledMarkup(WRITTEN_AFTER))) + .build(), + )) + } + + #[test] + fn a_change_to_markup_a_middleware_compiles_in_changes_the_fingerprint() { + // A fetch middleware's markup is stored with the template, and no + // setting holds markup compiled into a module's crate, so a fix to + // it has to select a new template or stored pages keep the old one. + let mut settings = create_test_settings(); + settings.select_module("testing", "testing.probe"); + + assert_eq!( + fingerprint_with_carried(&settings, writing_before), + fingerprint_with_carried(&settings, writing_before), + "the same markup must select the same template" + ); + assert_ne!( + fingerprint_with_carried(&settings, writing_before), + fingerprint_with_carried(&settings, writing_after), + "a change to compiled-in markup must select a different template" + ); + } + #[test] fn the_policy_file_counts_by_its_digest_and_not_its_text() { let with_policy = |sha256: &str, text: &str| { diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml index 161d26974f..488c14fbde 100644 --- a/crates/trusted-server-modules/Cargo.toml +++ b/crates/trusted-server-modules/Cargo.toml @@ -23,6 +23,7 @@ trusted-server-auction-protocol-openrtb = { workspace = true } trusted-server-audience-permutive = { workspace = true } trusted-server-bot-protection-datadome = { workspace = true } trusted-server-cmp-didomi = { workspace = true } +trusted-server-cmp-inmobi = { workspace = true } trusted-server-cmp-osano = { workspace = true } trusted-server-cmp-sourcepoint = { workspace = true } trusted-server-core = { workspace = true } diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 471f9ba364..c588a8738d 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -33,6 +33,7 @@ pub fn builders() -> Vec { trusted_server_audience_permutive::builder(), trusted_server_identity_lockr::builder(), trusted_server_cmp_didomi::builder(), + trusted_server_cmp_inmobi::builder(), trusted_server_cmp_sourcepoint::builder(), trusted_server_cmp_osano::builder(), trusted_server_tag_google_tag_manager::builder(), @@ -99,6 +100,7 @@ pub fn middleware() -> Vec { use trusted_server_audience_permutive as permutive; use trusted_server_bot_protection_datadome as datadome; use trusted_server_cmp_didomi as didomi; + use trusted_server_cmp_inmobi as inmobi; use trusted_server_cmp_sourcepoint as sourcepoint; use trusted_server_core::integrations::js_asset_proxy; use trusted_server_core::service_version; @@ -121,6 +123,7 @@ pub fn middleware() -> Vec { row(permutive::builder().id(), Fetch, permutive::MODULE), row(lockr::builder().id(), Fetch, lockr::MODULE), row(didomi::builder().id(), Fetch, didomi::MODULE), + row(inmobi::builder().id(), Fetch, inmobi::MODULE), row(sourcepoint::builder().id(), Fetch, sourcepoint::MODULE), row( google_tag_manager::builder().id(), @@ -175,6 +178,7 @@ mod tests { include_str!("../../audience/permutive/src/fixtures/page-change.settings.toml"), include_str!("../../identity/lockr/src/fixtures/page-change.settings.toml"), include_str!("../../cmp/didomi/src/fixtures/page-change.settings.toml"), + include_str!("../../cmp/inmobi/src/fixtures/page-change.settings.toml"), include_str!("../../cmp/sourcepoint/src/fixtures/page-change.settings.toml"), include_str!("../../tag/google-tag-manager/src/fixtures/page-change.settings.toml"), include_str!("../../bot-protection/datadome/src/fixtures/page-change.settings.toml"), @@ -374,6 +378,7 @@ mod tests { "audience.permutive", "identity.lockr", "cmp.didomi", + "cmp.inmobi", "cmp.sourcepoint", "cmp.osano", "tag.google-tag-manager", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 11bcff0f5b..601edde54b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -167,6 +167,7 @@ export default withMermaid( text: 'GPT Runtime Diagnostics', link: '/guide/integrations/gpt-diagnostics', }, + { text: 'InMobi Choice', link: '/guide/integrations/inmobi' }, { text: 'Lockr', link: '/guide/integrations/lockr' }, { text: 'Next.js', link: '/guide/integrations/nextjs' }, { text: 'Osano', link: '/guide/integrations/osano' }, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 60621a579d..a4aa28491c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1749,6 +1749,7 @@ validation knows is listed below. | `[tag.google-tag-manager]` | [Google Tag Manager](/guide/integrations/google_tag_manager) | | `[ad-tag.google]` | [GPT](/guide/integrations/gpt) | | `[ad-tag.google.diagnostics]` | [GPT diagnostics](/guide/integrations/gpt-diagnostics) | +| `[cmp.inmobi]` | [InMobi Choice](/guide/integrations/inmobi) | | `[proxy.js_asset_proxy]` | [JS Asset Proxy](#js-asset-proxy-integration) (no dedicated guide) | | `[identity.lockr]` | [lockr](/guide/integrations/lockr) | | `[framework.nextjs]` | [Next.js](/guide/integrations/nextjs) | @@ -1772,7 +1773,7 @@ module = "nextjs" ``` The modules this repository ships, by section, are `[cmp]` `didomi`, -`sourcepoint` and `osano`; `[tag]` `google-tag-manager`; `[ad-tag]` `google` +`inmobi`, `sourcepoint` and `osano`; `[tag]` `google-tag-manager`; `[ad-tag]` `google` and `google.diagnostics`; `[bot-protection]` `datadome`; `[identity]` `lockr`; `[audience]` `permutive`; `[framework]` `nextjs`; `[auction]` `prebid` and `testing.testlight`; and `[proxy]` `js_asset_proxy`. A name @@ -1861,6 +1862,7 @@ the names in and the order `trusted-server.example.toml` shows them in. | `audience.permutive` | `[[fetch]]` | Points a `src` or `href` that is the Permutive SDK's address at `/integrations/permutive/sdk`, when `rewrite_sdk` is set | | `identity.lockr` | `[[fetch]]` | Points a `src` or `href` that is the lockr SDK's address at `/integrations/lockr/sdk`, when `rewrite_sdk` is set | | `cmp.didomi` | `[[fetch]]` | Writes `window.__tsjs_didomi` into the head, which hands the browser module the path Didomi is served under | +| `cmp.inmobi` | `[[fetch]]` | Writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order | | `cmp.sourcepoint` | `[[fetch]]` | Writes `window.__tsjs_sourcepoint` into the head and, when `rewrite_sdk` is set, the trap on `window._sp_`, and points a `src` or `href` on Sourcepoint's CDN at `/integrations/sourcepoint/cdn` | | `tag.google-tag-manager` | `[[fetch]]` | Points Google Tag Manager and Google Analytics addresses at `/integrations/google_tag_manager`, in `src` and `href` attributes and in the text of inline scripts | | `bot-protection.datadome` | `[[fetch]]` | Points a `src` or `href` that is a DataDome script's address at `/integrations/datadome`, when `rewrite_sdk` is set | @@ -1940,6 +1942,21 @@ to new configurations. See [Didomi](/guide/integrations/didomi) for the routed endpoint shapes. +### InMobi Choice Integration + +**Section**: `[cmp.inmobi]` + +| Field | Type | Default | Contract | +| ------------ | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | +| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | +| `cmp_id` | Number | `10` | The IAB registered CMP identifier, from 1 to 4095, that the GPP stub answers `ping` with while the prompt loads | + +The table refuses a field it does not read. Selecting the module is refused +when the `[[fetch]]` entries leave any page without `cmp.inmobi`, because the +prompt would not be written there, and each entry names it before any +middleware that writes a vendor's tag. See [InMobi Choice](/guide/integrations/inmobi) +for the tag's parts and what the module keeps to. + ### Google Tag Manager Integration **Section**: `[tag.google-tag-manager]` @@ -2769,8 +2786,9 @@ Check `X-TS-Template-Cache: hit` to verify template reuse. `template_cache_vary` is necessary because lookup occurs before the origin can return `Vary`. Presence, empty values, repeated raw field values, host/scheme, -origin identity, complete template-shaping settings, TSJS content, core build -inputs, and schema version all participate in an opaque SHA-256 cache key. The +origin identity, complete template-shaping settings, TSJS content, the text +each middleware gives as its `template_key`, core build inputs, and schema +version all participate in an opaque SHA-256 cache key. The build digest covers every core source file (including Rust-inlined head scripts) except hidden, `#`-prefixed, and `~`-suffixed editor artifacts, plus the core build script, core and workspace manifests, `edgezero.toml`, and the workspace diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index f7d8897f5f..57c57e6fea 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -480,6 +480,11 @@ that names it refuses startup. | `MiddlewarePhase::Fetch` | On the page as the origin sent it. What it leaves is what a shared template stores | Nothing a request left | | `MiddlewarePhase::Serve` | On each reader's copy, whether the page came from the store or from the origin | What the module's request hooks left for it | +A stored page is keyed by the settings and core's build, so a fetch +middleware that writes markup or an address compiled into its own crate +returns that text from `template_key`, and a change to it stores pages +again. + A middleware is asked once for each document, by `create`, and what it answers is used for that document alone, so a handler may hold state between the chunks of one script. The answer is a `MiddlewareAction`, which may set diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md index b34621be32..eff502cdbb 100644 --- a/docs/guide/integrations-overview.md +++ b/docs/guide/integrations-overview.md @@ -35,6 +35,7 @@ their operational procedures. | [`google_tag_manager`](/guide/integrations/google_tag_manager) | production | `[tag] modules = ["google-tag-manager"]` | settings builder | bundled | | [`gpt`](/guide/integrations/gpt) | production | `[ad-tag] modules = ["google"]` | settings builder | bundled | | [`gpt_diagnostics`](/guide/integrations/gpt-diagnostics) | development | `[ad-tag] modules = ["google.diagnostics"]` | settings builder | standalone | +| [`inmobi`](/guide/integrations/inmobi) | development | `[cmp] module = "inmobi"` | settings builder | none | | `js_asset_proxy` (no dedicated guide) | development | `[proxy] modules = ["js_asset_proxy"]` | settings builder | none | | [`lockr`](/guide/integrations/lockr) | production | `[identity] module = "lockr"` | settings builder | bundled | | [`nextjs`](/guide/integrations/nextjs) | production | `[framework] module = "nextjs"` | settings builder | none | diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md new file mode 100644 index 0000000000..c72e54c0f6 --- /dev/null +++ b/docs/guide/integrations/inmobi.md @@ -0,0 +1,117 @@ +# InMobi Choice Integration + +**Category**: Consent Management Platform + +**Status**: Development + +**Type**: Consent prompt tag, with the IAB stubs ahead of it + +## Overview + +InMobi Choice is a TCF consent prompt loaded from the publisher's own InMobi +account. The module writes the prompt's tag into the head of each page a +`[[fetch]]` entry for `text/html` names it for, in three parts and in this +order: + +1. The IAB TCF v2 stub, which creates the `__tcfapiLocator` frame, queues + every `__tcfapi` call made before the prompt arrives, answers `ping` as a + stub, and relays calls posted from frames. +2. InMobi's GPP stub, which answers `ping` and the other generic `__gpp` + commands at once with the configured CMP id, queues every other call for + the prompt, creates the `__gppLocator` frame, and relays calls posted + from frames. +3. The account's Choice loader, deferred, so a slow consent vendor never + holds up the page a reader came for and the loader never runs ahead of a + stub the publisher's own head carries. + +Each stub defines nothing where a page already has its interface. Nothing +else of the page is changed, and the module serves no browser module of its +own, because the two stubs and the Choice script are the whole client side. + +The prompt writes the reader's answer to the `euconsent-v2` cookie, which +core reads on the next request. The TCF permission signal module reads the +record core decodes from it, so the answer reaches the permission model the +way every signal does. + +## Configuration + +```toml +[cmp] +module = "inmobi" + +[cmp.inmobi] +script_url = "https://cmp.inmobi.com/choice///choice.js?tag_version=V3" +# cmp_id = 10 + +[[fetch]] +media_type = "text/html" +middleware = ["cmp.inmobi", "tag.google-tag-manager"] +``` + +| Field | Type | Default | Contract | +| ------------ | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | +| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | +| `cmp_id` | Number | `10` | The IAB registered CMP identifier, from 1 to 4095, that the GPP stub answers `ping` with while the prompt loads | + +The table refuses a field it does not read. + +## Placing the prompt + +The prompt changes a page only where a `[[fetch]]` entry names `cmp.inmobi`, +in the fetch phase alone, because the tag is the same for every reader and +belongs in the page every reader is served from. Core writes each +middleware's head markup in the order the entry names them, and nothing +advertising related may run before the reader answers the prompt, so name +`cmp.inmobi` before any middleware that writes a vendor's tag, as the example +above does ahead of Google Tag Manager. + +Selecting the module is refused when the settings load and by `ts config +validate` if the entries leave any page served as `text/html` without the +prompt, and the refusal names those pages. A page takes the first entry that +covers it, so every entry names `cmp.inmobi`, and one with no `path` comes +after any that has one. + +The tag is written at the start of the head, ahead of the page's own +``, and is about 3.6 KB. A browser looks for `` +only in the first 1024 bytes of a page, so the origin should send the +encoding in `Content-Type` as well, as `text/html; charset=utf-8`, or a page +that declares it only in the meta tag has its encoding guessed. + +## What is checked + +`script_url` becomes a `