diff --git a/.cargo/config.toml b/.cargo/config.toml index 6da9679911..cc11c8e77d 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-cmp-inmobi -p trusted-server-ads-txt-sellers -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-ads-txt-sellers -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-ads-txt-sellers -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-ads-txt-sellers -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-ads-txt-sellers -p trusted-server-tag-google-tag -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-ads-txt-sellers -p trusted-server-tag-google-tag -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-ads-txt-sellers -p trusted-server-tag-google-tag -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-ads-txt-sellers -p trusted-server-tag-google-tag -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-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-loading-cache-51degrees -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-ads-txt-sellers -p trusted-server-loading-cache-51degrees -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-ads-txt-sellers -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag -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-ads-txt-sellers -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag -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/AGENTS.md b/AGENTS.md index 2a02425ab7..064ad08bd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ crates/ 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) + tag/ # tag module crates (google-tag, google-tag-manager) testing/ # test module crates (seam-probe, testlight) device/ fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device module) @@ -404,15 +404,15 @@ table for a selected name that has settings. The rules and what is checked when are in `docs/guide/configuration-rules.md`, which is the page to read before changing any module configuration. -| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | -| --------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | -| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | `hmac`, `host_signals`, `client_fixed` (opt-in, no default) | `crates/edgecookie/` | -| Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | `builtin`, User-Agent only (the default) | `crates/device/` | -| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | None, no location (the default), or `platform` | `crates/geo/` | -| Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | `gpc`, `gpp`, `us-privacy`, `tcf`, all of them with no list | `crates/permission-signal/` | -| Auction demand | `DemandImplementation` (`auction/demand.rs`) | `[demand] modules` (a list) | `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` | an integration builder | -| Ad server | `AdServerImplementation` (`auction/demand.rs`) | `[ad-server] module` | `mock` (`ad-server.mock`) | an integration builder | -| Page integrations | `IntegrationBuilder` (`integrations/mod.rs`) | the section of the module's type: `[cmp] module`, `[tag] modules`, `[ad-tag] modules`, `[bot-protection] module`, `[identity] module`, `[audience] module`, `[framework] module`, `[auction] modules`, `[proxy] modules` | `cmp.didomi`, `cmp.sourcepoint`, `cmp.osano`, `tag.google-tag-manager`, `ad-tag.google`, `ad-tag.google.diagnostics`, `bot-protection.datadome`, `identity.lockr`, `audience.permutive`, `framework.nextjs`, `auction.prebid`, `testing.testlight`, `js_asset_proxy` | an adapter-supplied builder | +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | `hmac`, `host_signals`, `client_fixed` (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | `builtin`, User-Agent only (the default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | None, no location (the default), or `platform` | `crates/geo/` | +| Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | `gpc`, `gpp`, `us-privacy`, `tcf`, all of them with no list | `crates/permission-signal/` | +| Auction demand | `DemandImplementation` (`auction/demand.rs`) | `[demand] modules` (a list) | `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` | an integration builder | +| Ad server | `AdServerImplementation` (`auction/demand.rs`) | `[ad-server] module` | `mock` (`ad-server.mock`) | an integration builder | +| Page integrations | `IntegrationBuilder` (`integrations/mod.rs`) | the section of the module's type: `[cmp] module`, `[tag] modules`, `[ad-tag] modules`, `[bot-protection] module`, `[identity] module`, `[audience] module`, `[framework] module`, `[auction] modules`, `[proxy] modules` | `cmp.didomi`, `cmp.sourcepoint`, `cmp.osano`, `tag.google-tag`, `tag.google-tag-manager`, `ad-tag.google`, `ad-tag.google.diagnostics`, `bot-protection.datadome`, `identity.lockr`, `audience.permutive`, `framework.nextjs`, `auction.prebid`, `testing.testlight`, `js_asset_proxy` | an adapter-supplied builder | `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` and `ad-server.mock` supply implementations only. No module section selects them. @@ -453,7 +453,7 @@ IntegrationRegistration::builder(ID) ``` - Integration IDs match JS directory names: `prebid` (deferred), `lockr`, `permutive`, `datadome`, `didomi`, `testlight`. -- An integration runs when the section of its type selects its module. A module with a crate of its own is named by that crate's path under `crates/` (`permission-signal.gpc`, `testing.seam-probe`). Each page integration lives in a crate of its own and holds that name in a `MODULE` constant (`cmp.didomi`, `tag.google-tag-manager`, `ad-tag.google`, `bot-protection.datadome`, `identity.lockr`, `audience.permutive`, `framework.nextjs`, `auction.prebid`, `testing.testlight`), which a test in the crate holds to the crate's folder. A name is written in its section with the type left off. There is no `enabled` flag and no `[integration]` table. +- An integration runs when the section of its type selects its module. A module with a crate of its own is named by that crate's path under `crates/` (`permission-signal.gpc`, `testing.seam-probe`). Each page integration lives in a crate of its own and holds that name in a `MODULE` constant (`cmp.didomi`, `tag.google-tag`, `tag.google-tag-manager`, `ad-tag.google`, `bot-protection.datadome`, `identity.lockr`, `audience.permutive`, `framework.nextjs`, `auction.prebid`, `testing.testlight`), which a test in the crate holds to the crate's folder. A name is written in its section with the type left off. There is no `enabled` flag and no `[integration]` table. - `creative` is JS-only (no Rust registration), and `nextjs` is Rust-only. `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` and `ad-server.mock` register demand or ad server implementations rather than page integrations. - Integrations opt into deferred loading via `.with_deferred_js()` on the registration builder. Deferred modules are served as separate `") + } + /// The attribute on the script bundle's tag that lets the bundle apply /// GAM attribution, when `gam_attribution_enabled` is set. fn bundle_tag_attribute(&self) -> Option<(&'static str, &'static str)> { @@ -643,8 +753,22 @@ mod tests { use super::*; use http::Method; use trusted_server_core::constants::HEADER_X_FORWARDED_FOR; - use trusted_server_core::integrations::IntegrationDocumentState; - use trusted_server_core::test_support::tests::create_test_settings; + use trusted_server_core::html_processor::HtmlProcessorConfig; + use trusted_server_core::html_processor::test_support::{ + ORIGIN_HOST, REQUEST_HOST, REQUEST_PATH, create_page_processor, place_on_every_page, + }; + use trusted_server_core::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use trusted_server_core::middleware::test_support as middleware_test_support; + use trusted_server_core::middleware::{ + HTML_MEDIA_TYPE, MiddlewareChain, MiddlewarePlan, Prepared, + }; + use trusted_server_core::module_context::ModuleContext; + use trusted_server_core::module_context::test_support::request; + use trusted_server_core::permissions::{ConsentSignal, PermissionMaps}; + use trusted_server_core::streaming_processor::StreamProcessor as _; + use trusted_server_core::test_support::tests::{ + create_test_settings, storage_only_permissions, + }; fn test_config() -> GptConfig { GptConfig { @@ -653,6 +777,7 @@ mod tests { cache_ttl_seconds: 3600, rewrite_script: true, slim_prebid_url: None, + write_loader: false, } } @@ -1587,6 +1712,295 @@ mod tests { ); } + /// The loader tag, as a page carries it. + const LOADER_TAG: &str = r#""#; + + /// An invented publisher's page that carries no GPT loader of its own, + /// which is the page `write_loader` is for. + const PAGE_WITHOUT_A_LOADER: &str = concat!( + "An article", + "
" + ); + + /// Settings selecting the module with `config` as its table. + fn settings_with_gpt(config: &serde_json::Value) -> Settings { + let mut settings = create_test_settings(); + settings + .insert_module_config("ad-tag", MODULE, config) + .expect("should select GPT"); + settings + } + + /// Settings selecting the module with `write_loader` set. + fn settings_writing_the_loader() -> Settings { + settings_with_gpt(&serde_json::json!({ "write_loader": true })) + } + + /// The loader middleware the module registers for `settings`. + fn loader_for(settings: &Settings) -> Arc { + register(settings) + .expect("should register GPT") + .expect("should run GPT") + .middleware + .into_iter() + .find(|middleware| middleware.middleware_id() == LOADER_MIDDLEWARE) + .expect("the module should register its loader middleware") + } + + /// What the loader middleware plans for one reader's copy, after the + /// serve phase's preparation has run with `module_context`, as core + /// prepares and plans every reader's copy. + fn loader_plan_with(settings: &Settings, module_context: &ModuleContext<'_>) -> MiddlewarePlan { + let chain = MiddlewareChain::new( + MiddlewarePhase::Serve, + HTML_MEDIA_TYPE, + vec![loader_for(settings)], + ); + let document_state = IntegrationDocumentState::default(); + let prepared = Prepared::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..middleware_test_support::context(MiddlewarePhase::Serve, &document_state) + }; + futures::executor::block_on(chain.prepare(module_context, &context)) + .expect("should prepare the middleware"); + chain.plan(&context).expect("should plan the middleware") + } + + /// What the loader middleware plans for a reader whose request resolved + /// to `permissions`. + fn loader_plan_for(settings: &Settings, permissions: &PermissionState) -> MiddlewarePlan { + loader_plan_with( + settings, + &ModuleContext::new(request(REQUEST_PATH)).with_permissions(permissions), + ) + } + + /// The page a reader whose request resolved to `permissions` receives of + /// `html`, with the module's fetch middleware named in a `[[fetch]]` + /// entry and its loader in a `[[serve]]` entry. Both phases are prepared + /// and then run on one pass, as core does for a page it does not store. + fn process_for(settings: &Settings, html: &str, permissions: &PermissionState) -> String { + let mut placed = settings.clone(); + place_on_every_page(&mut placed, MiddlewarePhase::Fetch, &[MODULE]); + place_on_every_page(&mut placed, MiddlewarePhase::Serve, &[LOADER_MIDDLEWARE]); + let registry = IntegrationRegistry::with_registrations(&placed, &[builder()]) + .expect("should build a registry with the module"); + + let prepared = Arc::new(Prepared::default()); + let module_context = ModuleContext::new(request(REQUEST_PATH)) + .with_settings(&placed) + .with_permissions(permissions); + for phase in MiddlewarePhase::ALL { + let chain = registry.middleware_chain( + placed.phase_entries(phase), + phase, + HTML_MEDIA_TYPE, + REQUEST_PATH, + ); + let document_state = IntegrationDocumentState::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..middleware_test_support::context(phase, &document_state) + }; + futures::executor::block_on(chain.prepare(&module_context, &context)) + .expect("should prepare the page's middleware"); + } + + let config = HtmlProcessorConfig::from_settings( + &placed, + ®istry, + ORIGIN_HOST, + REQUEST_HOST, + "https", + ) + .with_prepared(prepared); + let page = create_page_processor(&placed, ®istry, config) + .process_chunk(html.as_bytes(), true) + .expect("should process the page"); + String::from_utf8(page).expect("should leave the page UTF-8") + } + + #[test] + fn the_fetch_phase_writes_no_loader_tag_even_when_asked() { + // What the fetch phase writes is stored and served to every reader, + // so the loader tag, which depends on the reader's permissions, is + // never part of it. + let action = page_action(GptIntegration::new(GptConfig { + write_loader: true, + ..test_config() + })); + + assert!( + action + .head_inserts + .iter() + .all(|insert| !insert.contains("/integrations/gpt/script")), + "the fetch phase should write no loader tag, got: {:?}", + action.head_inserts + ); + } + + #[test] + fn a_reader_who_has_not_allowed_storage_gets_no_loader() { + // Arrange + // Every permission but storage on the device, which the loader needs. + let settings = settings_writing_the_loader(); + let all_but_storage: PermissionSet = Permission::all() + .filter(|permission| *permission != Permission::StoreOnDevice) + .collect(); + + // Act + let plan = loader_plan_for(&settings, &PermissionState::new(all_but_storage)); + + // Assert + assert!( + plan.head_inserts.is_empty(), + "no loader tag should be written: {:?}", + plan.head_inserts + ); + } + + #[test] + fn a_reader_who_allows_storage_gets_the_loader() { + // Arrange + let settings = settings_writing_the_loader(); + + // Act + let plan = loader_plan_for(&settings, &storage_only_permissions()); + + // Assert + assert_eq!( + plan.head_inserts, + vec![LOADER_TAG.to_owned()], + "should ask for the loader on this module's own route" + ); + } + + #[test] + fn no_loader_is_written_unless_asked() { + // Arrange + // A reader who allows storage on the device, and a table that leaves + // `write_loader` at its default. + let settings = settings_with_gpt(&serde_json::json!({})); + + // Act + let plan = loader_plan_for(&settings, &storage_only_permissions()); + + // Assert + assert!( + plan.head_inserts.is_empty(), + "no loader tag should be written unless asked: {:?}", + plan.head_inserts + ); + } + + #[test] + fn no_loader_is_written_where_no_permission_state_reached_the_middleware() { + // Arrange + // A preparation whose context carries no permission state, and a + // document nothing was prepared for. + let settings = settings_writing_the_loader(); + let document_state = IntegrationDocumentState::default(); + + // Act + let unresolved = loader_plan_with(&settings, &ModuleContext::new(request(REQUEST_PATH))); + let context = middleware_test_support::context(MiddlewarePhase::Serve, &document_state); + let unprepared = loader_for(&settings).create(&context); + + // Assert + assert!( + unresolved.head_inserts.is_empty(), + "no permission state: no loader tag should be written: {:?}", + unresolved.head_inserts + ); + assert!( + unprepared.head_inserts.is_empty(), + "nothing prepared: no loader tag should be written: {:?}", + unprepared.head_inserts + ); + } + + #[test] + fn a_page_for_a_reader_who_has_not_agreed_carries_no_gpt_loader() { + // Arrange + // Under the repository's sample policy, a reader in Germany whose + // request carries no signal is still waiting to allow storage on the + // device, and a reader in California who sends an opt-out, such as + // Global Privacy Control, has it revoked. + let settings = settings_writing_the_loader(); + let policy = PermissionMaps::sample(); + let readers = [ + ("not yet agreed", policy.baseline(Some("DE"), None)), + ( + "opted out", + policy.resolve_with(Some("US"), Some("CA"), |permission, _| { + if policy.signals().opt_out_revokes(permission) { + ConsentSignal::Revoke + } else { + ConsentSignal::Neutral + } + }), + ), + ]; + + for (reader, permissions) in readers { + // Act + let processed = process_for(&settings, PAGE_WITHOUT_A_LOADER, &permissions); + + // Assert + assert!( + processed.contains(GPT_BOOTSTRAP_JS), + "{reader}: the bootstrap should be written: {processed}" + ); + assert!( + !processed.contains("/integrations/gpt/script"), + "{reader}: no loader tag should reach the page: {processed}" + ); + } + } + + #[test] + fn a_page_for_a_reader_who_allows_storage_carries_the_loader_after_the_bootstrap() { + // Arrange + // Under the repository's sample policy, a reader in the United States + // who sends no opt-out allows storage on the device. + let settings = settings_writing_the_loader(); + let permissions = PermissionMaps::sample().baseline(Some("US"), None); + + // Act + let processed = process_for(&settings, PAGE_WITHOUT_A_LOADER, &permissions); + + // Assert + assert_eq!( + processed.matches(LOADER_TAG).count(), + 1, + "the loader tag should be written once: {processed}" + ); + // The script that installs the shim comes before the bootstrap, so a + // tag after the bootstrap's end follows both. + let bootstrap_end = processed + .find(GPT_BOOTSTRAP_JS) + .map(|at| at + GPT_BOOTSTRAP_JS.len()) + .expect("the bootstrap should be written"); + let loader = processed + .find(LOADER_TAG) + .expect("the loader tag should be written"); + assert!( + bootstrap_end <= loader, + "the loader tag should follow the shim and the bootstrap it relies on: {processed}" + ); + } + + #[test] + fn the_loader_middleware_is_named_after_the_module() { + assert_eq!( + LOADER_MIDDLEWARE, + trusted_server_core::module_name!("loader"), + "should be the module's name with a part of its own" + ); + } + #[test] fn module_constant_is_the_crate_folder() { assert_eq!( diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index b15c9e74bb..3924a8028d 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -915,6 +915,68 @@ fn build( Ok(Some(GoogleTagManagerIntegration::new(config))) } +/// Whether `value` has the shape of a tag id Google serves from `gtag/js`. +#[must_use] +pub fn tag_id_is_well_formed(value: &str) -> bool { + GTM_TAG_ID_PATTERN.is_match(value) +} + +/// Whether `value` has the shape of a container id. +#[must_use] +pub fn container_id_is_well_formed(value: &str) -> bool { + GTM_CONTAINER_ID_PATTERN.is_match(value) +} + +/// The first-party path this module serves `gtag/js` from for `tag_id`, or +/// `None` when it would not serve that tag, either because the module is not +/// selected or because `tag_id` is neither its container nor on its +/// `allowed_tag_ids` list, where a request for the tag is redirected upstream +/// rather than proxied, so naming the first-party path would only add a +/// redirect to the page's first script. +/// +/// # Errors +/// +/// When the module's settings cannot be read or fail their validation. +pub fn first_party_gtag_js_path( + settings: &Settings, + tag_id: &str, +) -> Result, Report> { + let Some(config) = settings.module_config::(MODULE)? else { + return Ok(None); + }; + let serves_tag = config.container_id == tag_id + || config + .allowed_tag_ids + .iter() + .any(|allowed| allowed == tag_id); + if !serves_tag { + return Ok(None); + } + Ok(Some(format!("/integrations/{GTM_INTEGRATION_ID}/gtag/js"))) +} + +/// The first-party path this module serves `gtm.js` from for `container_id`, +/// or `None` when it would not serve that container. The endpoint always +/// answers with the container this module is configured for and never with +/// the one the request names, so a first-party path is only right when the +/// two are the same container. +/// +/// # Errors +/// +/// When the module's settings cannot be read or fail their validation. +pub fn first_party_gtm_js_path( + settings: &Settings, + container_id: &str, +) -> Result, Report> { + let Some(config) = settings.module_config::(MODULE)? else { + return Ok(None); + }; + if config.container_id != container_id { + return Ok(None); + } + Ok(Some(format!("/integrations/{GTM_INTEGRATION_ID}/gtm.js"))) +} + /// Validates the Google Tag Manager configuration for deployment and reports whether /// a section selects the integration's module. /// diff --git a/crates/tag/google-tag/Cargo.toml b/crates/tag/google-tag/Cargo.toml new file mode 100644 index 0000000000..237b5dfc92 --- /dev/null +++ b/crates/tag/google-tag/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "trusted-server-tag-google-tag" +description = "Google's tag, Tag Manager container and AdSense loader written on the page from the settings, as a tag module." +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 } +log = { workspace = true } +regex = { workspace = true } +serde = { workspace = true } +trusted-server-core = { workspace = true } +trusted-server-tag-google-tag-manager = { workspace = true } +url = { workspace = true } +validator = { workspace = true } + +[dev-dependencies] +futures = { workspace = true } +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. Google 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/tag/google-tag/src/fixtures/head-with-its-own-tag.html b/crates/tag/google-tag/src/fixtures/head-with-its-own-tag.html new file mode 100644 index 0000000000..dab2f69a41 --- /dev/null +++ b/crates/tag/google-tag/src/fixtures/head-with-its-own-tag.html @@ -0,0 +1,32 @@ + + + + + + + Example Publisher + + + + + + + diff --git a/crates/tag/google-tag/src/fixtures/head-with-tag-manager.html b/crates/tag/google-tag/src/fixtures/head-with-tag-manager.html new file mode 100644 index 0000000000..d15e7406ce --- /dev/null +++ b/crates/tag/google-tag/src/fixtures/head-with-tag-manager.html @@ -0,0 +1,40 @@ + + + + + + + + Example Publisher + + + + + + + + diff --git a/crates/tag/google-tag/src/fixtures/page-change.settings.toml b/crates/tag/google-tag/src/fixtures/page-change.settings.toml new file mode 100644 index 0000000000..9dab3a1154 --- /dev/null +++ b/crates/tag/google-tag/src/fixtures/page-change.settings.toml @@ -0,0 +1,9 @@ +[tag] +modules = ["google-tag"] + +[tag.google-tag] +analytics_tag_id = "G-XXXXXXXXXX" + +[[serve]] +media_type = "text/html" +middleware = ["tag.google-tag"] diff --git a/crates/tag/google-tag/src/lib.rs b/crates/tag/google-tag/src/lib.rs new file mode 100644 index 0000000000..8f5aafc60b --- /dev/null +++ b/crates/tag/google-tag/src/lib.rs @@ -0,0 +1,1945 @@ +//! Google's tag, Tag Manager container and AdSense loader, put on the page +//! from the publisher's own configuration. +//! +//! This is not the Google Tag Manager integration next door. That one takes a +//! tag the page already carries and re-serves it from the publisher's own +//! origin. This one puts the tag on a page that carries none, which is what a +//! publisher needs when their templates forbid a third-party script and every +//! tag has to arrive from the proxy instead. +//! +//! The two work together. When `google-tag-manager` is also selected and it +//! serves the configured tag or container, the loader written here names the +//! first-party path rather than Google's host, so the script the browser +//! fetches is the one the publisher's own origin answers for. +//! +//! Every reader gets the consent mode defaults. The loaders and Tag Manager's +//! noscript iframe reach only a reader whose permissions allow storage on the +//! device, because each of them stores and reads Google's identifiers there. +//! +//! Everything emitted here is copied from Google's own documentation rather +//! than paraphrased, so a reader can hold the two side by side: +//! +//! - The tag snippet and where it goes: +//! +//! - Consent mode defaults and why they come first: +//! +//! - Installing a Tag Manager web container, which is the two-part snippet: +//! +//! - The AdSense code on every page: +//! +//! - The AdSense loader tag: +//! + +use std::any::Any; +use std::rc::Rc; +use std::sync::{Arc, LazyLock}; + +use error_stack::Report; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::integrations::{ + AttributeRewriteAction, IntegrationBuilder, IntegrationRegistration, +}; +use trusted_server_core::middleware::{ + AttributeRewrite, AttributeRewriteFn, ElementHandler, HTML_MEDIA_TYPE, Middleware, + MiddlewareAction, MiddlewareContext, MiddlewarePhase, Preparation, +}; +use trusted_server_core::module_context::ModuleCall; +use trusted_server_core::permissions::{Permission, PermissionSet, PermissionState}; +use trusted_server_core::settings::{IntegrationConfig, Settings}; +use trusted_server_tag_google_tag_manager as google_tag_manager; +use validator::{Validate, ValidationError}; + +/// The id this integration's registration carries. +const GOOGLE_TAG_INTEGRATION_ID: &str = "google_tag"; + +/// The name this module is selected by, in `[tag]`. +pub const MODULE: &str = "tag.google-tag"; + +/// The name a module's builder gives as its source, this crate's package. +const SOURCE: &str = "trusted-server-tag-google-tag"; + +/// The builder the registry runs when a section selects [`MODULE`]. +#[must_use] +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::new(GOOGLE_TAG_INTEGRATION_ID, SOURCE, register, validate) + .with_module_name(MODULE) +} + +/// Google's own address for the tag loader. +/// +/// From the install page, which gives the loader as +/// `https://www.googletagmanager.com/gtag/js?id=TAG_ID`. +const GTAG_JS_URL: &str = "https://www.googletagmanager.com/gtag/js"; + +/// Google's own address for the AdSense loader. +/// +/// From the ad unit code page, which gives the loader as +/// `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=…`. +const ADSENSE_JS_URL: &str = "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"; + +/// Google's own address for the Tag Manager container script. +/// +/// From the container install page, which builds it in the snippet as +/// `'https://www.googletagmanager.com/gtm.js?id='+i+dl`. +const GTM_JS_URL: &str = "https://www.googletagmanager.com/gtm.js"; + +/// Google's own address for the Tag Manager `noscript` iframe. +/// +/// This one has no first-party route behind it, so it is never substituted. +const GTM_NS_URL: &str = "https://www.googletagmanager.com/ns.html"; + +/// The path Google serves the tag loader from, and the one a first-party +/// route ends with. +const GTAG_JS_PATH_SUFFIX: &str = "/gtag/js"; + +/// An AdSense publisher id, `ca-pub-` and sixteen digits. +static ADSENSE_CLIENT_ID_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"^ca-pub-[0-9]{16}$").expect("the AdSense client id regex should compile") +}); + +/// Whether a consent type is allowed before the visitor has said anything. +/// +/// The names are Google's, from the consent mode guide, and they reach the +/// page as the words `granted` and `denied`. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ConsentState { + /// The default, and the only one allowed before a permission signal has + /// been read. + #[default] + Denied, + /// Allowed before the visitor has said anything, which only a publisher + /// who knows their own legal basis may configure. + Granted, +} + +impl ConsentState { + /// The word Google's consent command expects. + const fn as_command_value(self) -> &'static str { + match self { + Self::Denied => "denied", + Self::Granted => "granted", + } + } +} + +/// How long the tag waits for a consent management platform's update before +/// it fires with the defaults, when no setting says otherwise. +const DEFAULT_WAIT_FOR_UPDATE_MS: u32 = 500; + +const fn default_wait_for_update() -> u32 { + DEFAULT_WAIT_FOR_UPDATE_MS +} + +/// The four consent types Google requires for its own services, how long the +/// tag waits for a consent management platform before it fires with them, +/// and the regions they apply to. +/// +/// All four default to denied. That is Google's own recommended starting +/// point, and no Google identifier may be stored before a permission signal +/// has been read. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = validate_region_codes))] +pub struct ConsentDefaults { + /// Storage for advertising, such as cookies. + #[serde(default)] + pub ad_storage: ConsentState, + /// Sending user data to Google for advertising. + #[serde(default)] + pub ad_user_data: ConsentState, + /// Personalised advertising. + #[serde(default)] + pub ad_personalization: ConsentState, + /// Storage for analytics, such as cookies. + #[serde(default)] + pub analytics_storage: ConsentState, + /// How long, in milliseconds, the tag waits for a consent management + /// platform's update before it fires with these defaults, which Google's + /// consent guide asks for where a platform sets consent on the page. Zero + /// fires at once. + #[serde(default = "default_wait_for_update")] + #[validate(range(max = 60000, message = "must be at most 60000 milliseconds"))] + pub wait_for_update: u32, + /// The regions the defaults apply to, as ISO 3166-1 country codes or + /// ISO 3166-2 region codes such as `GB` or `US-CA`. Empty applies them + /// everywhere. Where set, every other visitor gets all four denied. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub region: Vec, +} + +impl Default for ConsentDefaults { + fn default() -> Self { + Self { + ad_storage: ConsentState::default(), + ad_user_data: ConsentState::default(), + ad_personalization: ConsentState::default(), + analytics_storage: ConsentState::default(), + wait_for_update: DEFAULT_WAIT_FOR_UPDATE_MS, + region: Vec::new(), + } + } +} + +/// Refuses a region that is not a country or region code, two capitals with +/// an optional dash and one to three capitals or digits, so the snippet only +/// ever carries codes. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] naming the first region that is not a code. +fn validate_region_codes(defaults: &ConsentDefaults) -> Result<(), ValidationError> { + static CODE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[A-Z]{2}(-[A-Z0-9]{1,3})?$").expect("the region code pattern compiles") + }); + if let Some(region) = defaults.region.iter().find(|code| !CODE.is_match(code)) { + let mut error = ValidationError::new("google_region_code"); + error.message = Some( + format!("region `{region}` must be a country or region code such as GB or US-CA") + .into(), + ); + return Err(error); + } + Ok(()) +} + +/// Configuration for the Google tag, the Tag Manager container and the +/// AdSense loader. +#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = validate_at_least_one_id))] +pub struct GoogleConfig { + /// A Google tag id, such as a GA4 measurement id `G-XXXXXXXXXX`. + /// + /// Leaving this out means no tag snippet is written, which is what a + /// publisher running AdSense on its own wants. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(custom(function = validate_analytics_tag_id))] + pub analytics_tag_id: Option, + /// A Tag Manager container id, `GTM-` and four to twenty characters. + /// + /// A container is a different thing from a tag. A tag id names one + /// property to measure and is placed with the `gtag.js` snippet, whereas a + /// container holds whatever tags its owner has configured inside it and is + /// placed with Tag Manager's own two-part installation code. A publisher + /// may have both, and setting both writes both. + /// + /// Leaving this out means no container is written. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(custom(function = validate_container_id))] + pub container_id: Option, + /// An AdSense publisher id, `ca-pub-` followed by sixteen digits. + /// + /// Leaving this out means no AdSense loader is written. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(custom(function = validate_adsense_client_id))] + pub adsense_client_id: Option, + /// What each consent type is before the visitor has said anything. + #[serde(default)] + #[validate(nested)] + pub consent_defaults: ConsentDefaults, +} + +impl IntegrationConfig for GoogleConfig {} + +/// Refuses a configuration naming no tag, no container and no AdSense account. +/// +/// Such a configuration selects the integration and then asks it to write +/// nothing, which reads on the page as the integration being broken rather +/// than as the configuration being empty. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] when all three ids are absent. +fn validate_at_least_one_id(config: &GoogleConfig) -> Result<(), ValidationError> { + if config.analytics_tag_id.is_none() + && config.container_id.is_none() + && config.adsense_client_id.is_none() + { + let mut error = ValidationError::new("google_no_ids"); + error.message = Some( + "[tag.google-tag] needs at least one of analytics_tag_id, container_id and \ + adsense_client_id" + .into(), + ); + return Err(error); + } + Ok(()) +} + +/// Refuses a tag id Google does not serve from `gtag/js`. +/// +/// The shape is the one the Google Tag Manager integration already accepts. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] when the id is not a Google tag id. +fn validate_analytics_tag_id(value: &str) -> Result<(), ValidationError> { + if google_tag_manager::tag_id_is_well_formed(value) { + return Ok(()); + } + let mut error = ValidationError::new("google_tag_id"); + error.message = + Some("analytics_tag_id must be a G-, GT-, GTM-, AW-, DC-, MC- or UA- tag id".into()); + Err(error) +} + +/// Refuses a container id Google Tag Manager would not recognise. +/// +/// The shape is the one the Google Tag Manager integration already accepts. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] when the id is not a container id. +fn validate_container_id(value: &str) -> Result<(), ValidationError> { + if google_tag_manager::container_id_is_well_formed(value) { + return Ok(()); + } + let mut error = ValidationError::new("google_container_id"); + error.message = Some("container_id must be GTM- followed by 4 to 20 capitals or digits".into()); + Err(error) +} + +/// Refuses an AdSense publisher id that is not `ca-pub-` and sixteen digits. +/// +/// # Errors +/// +/// Returns a [`ValidationError`] when the id does not match. +fn validate_adsense_client_id(value: &str) -> Result<(), ValidationError> { + if ADSENSE_CLIENT_ID_PATTERN.is_match(value) { + return Ok(()); + } + let mut error = ValidationError::new("google_adsense_client_id"); + error.message = Some("adsense_client_id must be ca-pub- followed by sixteen digits".into()); + Err(error) +} + +/// The consent defaults command. +/// +/// Copied from . +/// It carries its own `dataLayer` and `gtag` lines because it has to run +/// before anything else, including before the tag snippet that would +/// otherwise define them, and because it is written even when no tag is +/// configured and AdSense alone reads it. +/// +/// Where regions are set, the command for them is followed by one with no +/// region, which the guide says sets the default for every visitor no +/// regional command covers. That one denies all four types, because a grant +/// in the settings holds only in the regions they list. +fn consent_defaults_snippet(defaults: &ConsentDefaults) -> String { + let commands = if defaults.region.is_empty() { + consent_default_command(defaults, "") + } else { + // The regions are validated as codes, so the quotes cannot be escaped. + let codes: Vec = defaults + .region + .iter() + .map(|code| format!("'{code}'")) + .collect(); + let region = format!(",\n 'region': [{}]", codes.join(", ")); + let elsewhere = ConsentDefaults { + wait_for_update: defaults.wait_for_update, + ..ConsentDefaults::default() + }; + let regional = consent_default_command(defaults, ®ion); + let rest = consent_default_command(&elsewhere, ""); + format!("{regional}\n\n {rest}") + }; + format!( + "" + ) +} + +/// One `gtag('consent', 'default', …)` command carrying `defaults`, with +/// `region` after the wait, which is empty or the command's `region` +/// parameter. +fn consent_default_command(defaults: &ConsentDefaults, region: &str) -> String { + format!( + "gtag('consent', 'default', {{\n \ + 'ad_storage': '{ad_storage}',\n \ + 'ad_user_data': '{ad_user_data}',\n \ + 'ad_personalization': '{ad_personalization}',\n \ + 'analytics_storage': '{analytics_storage}',\n \ + 'wait_for_update': {wait_for_update}{region}\n \ + }});", + ad_storage = defaults.ad_storage.as_command_value(), + ad_user_data = defaults.ad_user_data.as_command_value(), + ad_personalization = defaults.ad_personalization.as_command_value(), + analytics_storage = defaults.analytics_storage.as_command_value(), + wait_for_update = defaults.wait_for_update, + ) +} + +/// Google's tag snippet. +/// +/// Copied from , +/// which says to place it "immediately after the opening `` HTML tag on +/// every page you want to measure". The middleware writes it in the head +/// immediately before the script bundle's tag, so after whatever the fetch +/// phase wrote there. The only substitutions are the tag id and, where the +/// Google Tag Manager integration serves this tag, the loader address. +fn analytics_snippet(tag_id: &str, src: &str) -> String { + format!( + "\n\ + \n\ + " + ) +} + +/// Tag Manager's container script, the first of its two parts. +/// +/// Copied from , which +/// says to paste it "as high in the `` tag as possible". The only +/// substitutions are the container id and, where this origin serves the same +/// container, the address the snippet builds. +fn container_script_snippet(container_id: &str, gtm_js_src: &str) -> String { + format!( + "\n\ + \n\ + " + ) +} + +/// Tag Manager's `noscript` iframe, the second of its two parts. +/// +/// Copied from the same page, which says to paste it "immediately after the +/// opening `` tag". That is why the middleware writes it at the start of +/// ``, because Google places this markup there and nowhere else. +/// +/// The address is never substituted, because `ns.html` has no first-party +/// route behind it. +fn container_noscript_snippet(container_id: &str) -> String { + format!( + "\n\ + \n\ + " + ) +} + +/// The AdSense page-level loader. +/// +/// Copied from . The +/// per-unit `` markup is not written here, because where a unit goes is +/// a property of the publisher's own page rather than of this configuration. +fn adsense_snippet(client_id: &str) -> String { + format!( + "" + ) +} + +/// Whether `value` is a `gtag/js` loader naming `tag_id`. +/// +/// Matching is on the parsed path and the `id` parameter rather than on the +/// whole string, so a loader carrying other parameters, a protocol-relative +/// address or a first-party path is recognised as the same thing. +fn url_is_gtag_js_for(value: &str, tag_id: &str) -> bool { + let trimmed = value.trim(); + // A protocol-relative or rooted address carries no scheme or host of its + // own, so it is given one purely to be parsed. Nothing reads it afterwards. + let absolute = if trimmed.starts_with("//") { + format!("https:{trimmed}") + } else if trimmed.starts_with('/') { + format!("https://placeholder.invalid{trimmed}") + } else { + trimmed.to_owned() + }; + let Ok(parsed) = url::Url::parse(&absolute) else { + return false; + }; + + if !parsed.path().ends_with(GTAG_JS_PATH_SUFFIX) { + return false; + } + + parsed + .query_pairs() + .any(|(name, value)| name == "id" && value == tag_id) +} + +/// The integration, holding what it will write. +struct GoogleIntegration { + config: GoogleConfig, + /// Where the tag loader points. + /// + /// Google's own address, or the first-party path when the Google Tag + /// Manager module is selected and serves this tag. Settled once at + /// startup because it depends on configuration rather than on the + /// request, and because the middleware runs on every document. + gtag_js_src: Option, + /// Where the container script's own loader points, decided the same way. + gtm_js_src: Option, +} + +impl GoogleIntegration { + /// The integration for `config`, with both loader addresses settled. + const fn new( + config: GoogleConfig, + gtag_js_src: Option, + gtm_js_src: Option, + ) -> Self { + Self { + config, + gtag_js_src, + gtm_js_src, + } + } +} + +impl GoogleIntegration { + /// The snippets for one reader, in the order Google's own documentation + /// requires. + /// + /// Consent defaults first, because the consent guide says the default + /// command must come "before any commands that send measurement data + /// (such as `config` or `event`)". They load nothing, so every reader gets + /// them. The container next, because Tag Manager asks to be as high in the + /// head as possible and it may carry tags of its own that send data. Then + /// the tag snippet, then the AdSense loader. Those three are loaders, so + /// they are written only where `loaders_allowed`. + fn page_inserts(&self, loaders_allowed: bool) -> Vec { + let mut inserts = vec![consent_defaults_snippet(&self.config.consent_defaults)]; + if !loaders_allowed { + return inserts; + } + + if let (Some(container_id), Some(src)) = ( + self.config.container_id.as_deref(), + self.gtm_js_src.as_deref(), + ) { + inserts.push(container_script_snippet(container_id, src)); + } + + if let (Some(tag_id), Some(src)) = ( + self.config.analytics_tag_id.as_deref(), + self.gtag_js_src.as_deref(), + ) { + inserts.push(analytics_snippet(tag_id, src)); + } + + if let Some(client_id) = self.config.adsense_client_id.as_deref() { + inserts.push(adsense_snippet(client_id)); + } + + inserts + } +} + +impl GoogleIntegration { + /// Tag Manager's `noscript` iframe, and nothing else, where + /// `loaders_allowed`, because the iframe loads Google's page. + /// + /// Google's container install page puts this part immediately after the + /// opening `` tag, so it is the one piece of this integration's + /// markup that does not belong in the head. + fn body_inserts(&self, loaders_allowed: bool) -> Vec { + if !loaders_allowed { + return Vec::new(); + } + self.config + .container_id + .as_deref() + .map(container_noscript_snippet) + .into_iter() + .collect() + } +} + +impl GoogleIntegration { + /// Drops the page's own loader for the configured tag, for every reader. + /// + /// Where this module writes its loader, a page also carrying the loader + /// for the same id would fetch the same library twice, and where the + /// reader's permissions keep this module's loader off the page, the + /// page's own would load it anyway, so the element is removed rather than + /// rewritten. The loader is recognised on its own host and on a + /// first-party path, which is how it arrives here once the fetch phase's + /// rewrite has made it first party. + /// + /// **An inline `gtag('config', …)` in the publisher's own page is left + /// alone and will still send a second page view.** The module's guide + /// says the same. + fn change_for_src(&self, value: &str) -> AttributeRewriteAction { + let Some(tag_id) = self.config.analytics_tag_id.as_deref() else { + return AttributeRewriteAction::Keep; + }; + + if url_is_gtag_js_for(value, tag_id) { + // The id is the publisher's own configured value, so it is not + // logged. The message says what happened and not which tag. + log::debug!( + "Google: the page carries its own loader for the configured tag, \ + so it is dropped" + ); + return AttributeRewriteAction::RemoveElement; + } + + AttributeRewriteAction::Keep + } +} + +/// What Google's loaders and Tag Manager's noscript iframe need from a reader, +/// being storage on the device, because each of them stores and reads +/// Google's identifiers there. +const LOADERS_REQUIRE: PermissionSet = PermissionSet::none().with(Permission::StoreOnDevice); + +/// Whether one reader's permissions allow the loaders, as the middleware's +/// preparation found them. +struct LoadersAllowed(bool); + +/// Writes the consent defaults onto every reader's copy, and the loaders and +/// the noscript iframe onto the copy of a reader whose permissions allow +/// storage on the device. Removes the page's own loader for the configured +/// tag for every reader. +/// +/// Serve only, because it adds to the page, and the removal belongs with the +/// addition it makes room for. +struct PageTag(Arc); + +impl PageTag { + /// Whether `permissions` set everything the loaders need. + fn loaders_allowed(&self, permissions: &PermissionState) -> bool { + permissions.all_set(self.required_permissions()) + } +} + +impl Middleware for PageTag { + fn middleware_id(&self) -> &'static str { + MODULE + } + + fn handles_media_type(&self, media_type: &str) -> bool { + media_type == HTML_MEDIA_TYPE + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Serve] + } + + fn required_permissions(&self) -> PermissionSet { + LOADERS_REQUIRE + } + + /// Reads the reader's permissions, which core hands every serve + /// preparation. A preparation handed none treats the reader as one whose + /// permissions do not allow the loaders. + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + _context: &'a MiddlewareContext<'a>, + ) -> Option> { + let allowed = matches!(call.inject(self, Self::loaders_allowed), Ok(true)); + Some(Box::pin(async move { + Ok(Box::new(LoadersAllowed(allowed)) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + // A document nothing was prepared for gets no loader. + let loaders_allowed = context + .take_prepared::(MODULE) + .is_some_and(|prepared| prepared.0); + let mut element_handlers: Vec> = Vec::new(); + if self.0.config.analytics_tag_id.is_some() { + let integration = Arc::clone(&self.0); + let change: Rc = + Rc::new(move |matched| integration.change_for_src(matched.value)); + element_handlers.push(Box::new(AttributeRewrite::new("src", change))); + } + MiddlewareAction { + head_inserts: self.0.page_inserts(loaders_allowed), + body_open_inserts: self.0.body_inserts(loaders_allowed), + element_handlers, + ..MiddlewareAction::pass() + } + } +} + +/// Validates the Google configuration and reports whether a section selects +/// the module. +/// +/// # Errors +/// +/// Returns an error when the configuration cannot be parsed or fails +/// validation. +pub fn validate(settings: &Settings) -> Result> { + Ok(settings.module_config::(MODULE)?.is_some()) +} + +/// Register the Google tag integration when a section selects it. +/// +/// # Errors +/// +/// Returns an error when the Google configuration, or the Google Tag Manager +/// configuration consulted for first-party delivery, cannot be parsed or +/// fails validation. +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(config) = settings.module_config::(MODULE)? else { + return Ok(None); + }; + + // Where each loader points is decided once, here, because it depends on + // another module's configuration rather than on the request. + let gtag_js_src = match config.analytics_tag_id.as_deref() { + Some(tag_id) => Some( + google_tag_manager::first_party_gtag_js_path(settings, tag_id)? + .unwrap_or_else(|| GTAG_JS_URL.to_owned()), + ), + None => None, + }; + + let gtm_js_src = match config.container_id.as_deref() { + Some(container_id) => Some( + google_tag_manager::first_party_gtm_js_path(settings, container_id)? + .unwrap_or_else(|| GTM_JS_URL.to_owned()), + ), + None => None, + }; + + let integration = Arc::new(GoogleIntegration::new(config, gtag_js_src, gtm_js_src)); + + Ok(Some( + IntegrationRegistration::builder(GOOGLE_TAG_INTEGRATION_ID) + .with_middleware(Arc::new(PageTag(integration))) + .build(), + )) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use trusted_server_core::html_processor::HtmlProcessorConfig; + use trusted_server_core::html_processor::test_support::{ + ORIGIN_HOST, REQUEST_HOST, REQUEST_PATH, create_page_processor, place_on_every_page, + }; + use trusted_server_core::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use trusted_server_core::middleware::test_support as middleware_test_support; + use trusted_server_core::middleware::{MiddlewareChain, MiddlewarePlan, Prepared}; + use trusted_server_core::module_context::ModuleContext; + use trusted_server_core::module_context::test_support::request; + use trusted_server_core::permissions::{ConsentSignal, PermissionMaps}; + use trusted_server_core::streaming_processor::StreamProcessor as _; + use trusted_server_core::test_support::tests::{ + create_test_settings, storage_only_permissions, + }; + + /// An invented tag id of the shape Google's own pages use. + const TEST_TAG_ID: &str = "G-XXXXXXXXXX"; + /// An invented AdSense account of the shape Google's own pages use. + const TEST_ADSENSE_CLIENT_ID: &str = "ca-pub-0000000000000000"; + /// An invented container id. + const TEST_CONTAINER_ID: &str = "GTM-EXAMPLE1"; + + /// Google's snippet as the install page gives it. + const GOOGLE_PUBLISHED_TAG_SNIPPET: &str = "\n\ + \n\ + "; + + /// Google's AdSense loader exactly as + /// gives it. + const GOOGLE_PUBLISHED_ADSENSE_LOADER: &str = concat!( + "" + ); + /// Tag Manager's container script exactly as + /// gives it. + const GOOGLE_PUBLISHED_CONTAINER_SCRIPT: &str = "\n\ + \n\ + "; + + /// Tag Manager's `noscript` part, from the same page. + const GOOGLE_PUBLISHED_CONTAINER_NOSCRIPT: &str = "\n\ + \n\ + "; + + /// An invented publisher's head carrying its own tag manager, so the + /// insertion is checked against a head shaped the way a content + /// management system writes one. + const HEAD_WITH_TAG_MANAGER: &str = include_str!("fixtures/head-with-tag-manager.html"); + /// An invented publisher's head that already loads the configured tag. + const HEAD_WITH_ITS_OWN_TAG: &str = include_str!("fixtures/head-with-its-own-tag.html"); + + /// Settings selecting the `google-tag` module with `config`. + fn settings_with_google(config: &serde_json::Value) -> Settings { + let mut settings = create_test_settings(); + settings + .insert_module_config("tag", MODULE, config) + .expect("should write the google integration config"); + settings + } + + /// Settings selecting both `google-tag` and `google-tag-manager`. + fn settings_with_google_and_tag_manager( + google: &serde_json::Value, + tag_manager: &serde_json::Value, + ) -> Settings { + let mut settings = settings_with_google(google); + settings + .insert_module_config("tag", google_tag_manager::MODULE, tag_manager) + .expect("should write the google_tag_manager config"); + settings + } + + /// The registration a named google integration produces for `settings`. + fn registration_for(settings: &Settings) -> IntegrationRegistration { + register(settings) + .expect("should read the google config") + .expect("a named google integration should register") + } + + /// The refusal `register` answers `settings` with. + /// + /// `IntegrationRegistration` holds trait objects and has no `Debug`, so + /// the error is taken with a pattern rather than with `expect_err`. + fn refusal_for(settings: &Settings) -> String { + let Err(error) = register(settings) else { + panic!("should refuse the configuration"); + }; + format!("{error:?}") + } + + /// The middleware a named module registers for `settings`. + fn page_tag_for(settings: &Settings) -> Arc { + Arc::clone( + registration_for(settings) + .middleware + .first() + .expect("the module should register its middleware"), + ) + } + + /// What the middleware plans for one reader's copy, after the serve + /// phase's preparation has run with `module_context`, as core prepares + /// and plans every reader's copy. + fn plan_with(settings: &Settings, module_context: &ModuleContext<'_>) -> MiddlewarePlan { + let chain = MiddlewareChain::new( + MiddlewarePhase::Serve, + HTML_MEDIA_TYPE, + vec![page_tag_for(settings)], + ); + let document_state = IntegrationDocumentState::default(); + let prepared = Prepared::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..middleware_test_support::context(MiddlewarePhase::Serve, &document_state) + }; + futures::executor::block_on(chain.prepare(module_context, &context)) + .expect("should prepare the middleware"); + chain.plan(&context).expect("should plan the middleware") + } + + /// What the middleware plans for a reader whose request resolved to + /// `permissions`. + fn plan_for(settings: &Settings, permissions: &PermissionState) -> MiddlewarePlan { + plan_with( + settings, + &ModuleContext::new(request(REQUEST_PATH)).with_permissions(permissions), + ) + } + + /// The head snippets the middleware writes for `settings`, for a reader + /// who allows storage on the device. + fn head_inserts_for(settings: &Settings) -> Vec { + plan_for(settings, &storage_only_permissions()).head_inserts + } + + /// `html` after the serve phase has run the middleware for `settings`, + /// for a reader who allows storage on the device. + fn process(settings: &Settings, html: &str) -> String { + process_for(settings, html, &[], &storage_only_permissions()) + } + + /// The page a reader whose request resolved to `permissions` receives of + /// `html`, with this module's middleware named in a `[[serve]]` entry and + /// the middleware in `fetch` named in a `[[fetch]]` entry. Both phases are + /// prepared and then run on one pass, as core does for a page it does not + /// store. + fn process_for( + settings: &Settings, + html: &str, + fetch: &[&str], + permissions: &PermissionState, + ) -> String { + let mut placed = settings.clone(); + if !fetch.is_empty() { + place_on_every_page(&mut placed, MiddlewarePhase::Fetch, fetch); + } + place_on_every_page(&mut placed, MiddlewarePhase::Serve, &[MODULE]); + let registry = IntegrationRegistry::with_registrations( + &placed, + &[builder(), google_tag_manager::builder()], + ) + .expect("should build a registry with the google modules"); + + let prepared = Arc::new(Prepared::default()); + let module_context = ModuleContext::new(request(REQUEST_PATH)) + .with_settings(&placed) + .with_permissions(permissions); + for phase in MiddlewarePhase::ALL { + let chain = registry.middleware_chain( + placed.phase_entries(phase), + phase, + HTML_MEDIA_TYPE, + REQUEST_PATH, + ); + let document_state = IntegrationDocumentState::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..middleware_test_support::context(phase, &document_state) + }; + futures::executor::block_on(chain.prepare(&module_context, &context)) + .expect("should prepare the page's middleware"); + } + + let config = HtmlProcessorConfig::from_settings( + &placed, + ®istry, + ORIGIN_HOST, + REQUEST_HOST, + "https", + ) + .with_prepared(prepared); + let page = create_page_processor(&placed, ®istry, config) + .process_chunk(html.as_bytes(), true) + .expect("should process the page"); + String::from_utf8(page).expect("should leave the page UTF-8") + } + + #[test] + fn module_constant_is_the_crate_folder() { + assert_eq!( + super::MODULE, + trusted_server_core::module_name!(), + "should be named by the folder this crate lives in" + ); + } + + #[test] + fn an_unnamed_integration_does_not_register() { + // Arrange + let settings = create_test_settings(); + + // Act + let registration = register(&settings).expect("should read an unnamed integration"); + + // Assert + assert!( + registration.is_none(), + "a google integration nothing names should not register" + ); + } + + #[test] + fn a_config_naming_no_id_is_refused() { + // Arrange + let settings = settings_with_google(&json!({})); + + // Act + let refusal = refusal_for(&settings); + + // Assert + assert!( + refusal.contains("analytics_tag_id") + && refusal.contains("container_id") + && refusal.contains("adsense_client_id"), + "the refusal should name all three settings, one of which is needed: {refusal}" + ); + } + + #[test] + fn an_id_of_the_wrong_shape_is_refused_by_name() { + // Arrange + // A tag id with a prefix Google does not serve, a container id in the + // wrong case, and an AdSense id missing its `ca-` prefix. + let cases = [ + ("analytics_tag_id", "GA-XXXXXXXXXX"), + ("container_id", "GTM-lowercase"), + ("adsense_client_id", "pub-0000000000000000"), + ]; + + for (setting, value) in cases { + let settings = settings_with_google(&json!({ setting: value })); + + // Act + let refusal = refusal_for(&settings); + + // Assert + assert!( + refusal.contains(setting), + "the refusal should name `{setting}` as the setting at fault: {refusal}" + ); + } + } + + #[test] + fn an_unknown_setting_is_refused() { + // Arrange + let settings = settings_with_google(&json!({ + "analytics_tag_id": TEST_TAG_ID, + "analytics_tag": "G-OTHER", + })); + + // Act + let refusal = refusal_for(&settings); + + // Assert + assert!( + refusal.contains("analytics_tag") || refusal.contains("unknown field"), + "the refusal should name the unknown setting: {refusal}" + ); + } + + /// The value `trusted-server.example.toml` gives `setting` in its + /// `[tag.google-tag]` table. + fn example_value(setting: &str) -> String { + let prefix = format!("# {setting} = \""); + trusted_server_core::test_support::template::EXAMPLE_TEMPLATE + .lines() + .skip_while(|line| *line != "# [tag.google-tag]") + .take_while(|line| line.starts_with('#')) + .find_map(|line| line.strip_prefix(prefix.as_str())?.strip_suffix('"')) + .unwrap_or_else(|| panic!("the example should give `{setting}` a value")) + .to_owned() + } + + #[test] + fn the_example_s_placeholder_ids_are_refused() { + // Arrange, Act, Assert + // A table uncommented with its placeholders left in should be refused, + // rather than write a tag or a container that is not the publisher's. + for setting in ["analytics_tag_id", "container_id"] { + let placeholder = example_value(setting); + let refusal = refusal_for(&settings_with_google(&json!({ setting: placeholder }))); + assert!( + refusal.contains(setting), + "the example's `{placeholder}` should be refused for `{setting}`: {refusal}" + ); + } + } + + #[test] + fn the_tag_snippet_is_googles_own_with_the_id_substituted() { + // Arrange + let settings = settings_with_google(&json!({ "analytics_tag_id": TEST_TAG_ID })); + let expected = GOOGLE_PUBLISHED_TAG_SNIPPET.replace("TAG_ID", TEST_TAG_ID); + + // Act + let inserts = head_inserts_for(&settings); + + // Assert + let written = inserts + .iter() + .find(|insert| insert.starts_with("") + .expect("should keep the publisher's own tag manager"); + assert!( + ours < theirs, + "the snippet should come before the publisher's own scripts" + ); + assert_eq!( + processed.matches("gtag('config'").count(), + 1, + "the tag should be configured once" + ); + } + + #[test] + fn a_page_already_loading_the_same_tag_ends_with_one_loader() { + // Arrange + // A page carrying its own loader for the tag this integration writes + // would otherwise fetch the same library twice. + let settings = settings_with_google(&json!({ "analytics_tag_id": TEST_TAG_ID })); + + // Act + let processed = process(&settings, HEAD_WITH_ITS_OWN_TAG); + + // Assert + assert_eq!( + processed.matches("gtag/js?id=").count(), + 1, + "the page should end with one loader for the tag, not two: {processed}" + ); + } + + #[test] + fn a_loader_the_fetch_rewrite_made_first_party_is_removed_as_well() { + // Arrange + // By the time the middleware runs, a fetch phase rewrite may already have + // pointed the page's own loader at the first-party path. + let settings = settings_with_google(&json!({ "analytics_tag_id": TEST_TAG_ID })); + let html = format!( + "" + ); + + // Act + let processed = process(&settings, &html); + + // Assert + assert_eq!( + processed.matches("gtag/js?id=").count(), + 1, + "the page should end with one loader for the tag, not two: {processed}" + ); + } + + #[test] + fn a_page_through_both_modules_ends_with_one_first_party_loader() { + // Arrange + // The Tag Manager module serves the configured tag, so its fetch + // middleware points the page's own loader at the first-party path, and + // this module's serve middleware then removes that loader and writes + // its own, which names the same path. + let settings = settings_with_google_and_tag_manager( + &json!({ "analytics_tag_id": TEST_TAG_ID }), + &json!({ + "container_id": "GTM-ABCD1234", + "allowed_tag_ids": [TEST_TAG_ID], + }), + ); + + // Act + let processed = process_for( + &settings, + HEAD_WITH_ITS_OWN_TAG, + &[google_tag_manager::MODULE], + &storage_only_permissions(), + ); + + // Assert + assert_eq!( + processed.matches("gtag/js?id=").count(), + 1, + "the page should end with one loader for the tag: {processed}" + ); + assert!( + processed.contains(&format!( + "src=\"/integrations/google_tag_manager/gtag/js?id={TEST_TAG_ID}\"" + )), + "the loader should name the first-party path: {processed}" + ); + assert!( + !processed.contains("www.googletagmanager.com/gtag/js"), + "nothing should load the tag from Google's host: {processed}" + ); + } + + #[test] + fn a_page_loading_another_tag_keeps_it() { + // Arrange + let settings = settings_with_google(&json!({ "analytics_tag_id": TEST_TAG_ID })); + let html = "Example Publisher\ + "; + + // Act + let processed = process(&settings, html); + + // Assert + assert!( + processed.contains("id=G-OTHERTAG"), + "a second property the publisher measures is theirs to keep" + ); + assert!( + processed.contains(&format!("id={TEST_TAG_ID}")), + "the configured tag should still be written" + ); + } + + #[test] + fn the_container_script_is_googles_own_with_the_id_substituted() { + // Arrange + let settings = settings_with_google(&json!({ "container_id": TEST_CONTAINER_ID })); + let expected = GOOGLE_PUBLISHED_CONTAINER_SCRIPT.replace("CONTAINER_ID", TEST_CONTAINER_ID); + + // Act + let inserts = head_inserts_for(&settings); + + // Assert + let written = inserts + .iter() + .find(|insert| insert.starts_with("")) + .expect("should write the container script"); + assert_eq!( + written, &expected, + "the container script should be Google's own, changed only where the id goes" + ); + } + + #[test] + fn the_container_noscript_is_googles_own_with_the_id_substituted() { + // Arrange + let settings = settings_with_google(&json!({ "container_id": TEST_CONTAINER_ID })); + let expected = + GOOGLE_PUBLISHED_CONTAINER_NOSCRIPT.replace("CONTAINER_ID", TEST_CONTAINER_ID); + + // Act + let processed = process(&settings, ""); + + // Assert + assert!( + processed.contains(&format!("{expected}")), + "the noscript part should be Google's own, changed only where the id goes, and \ + written immediately after the opening body tag: {processed}" + ); + } + + #[test] + fn consent_defaults_come_before_the_container() { + // Arrange + let settings = settings_with_google(&json!({ "container_id": TEST_CONTAINER_ID })); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + let consent = written + .find("gtag('consent', 'default'") + .expect("should write the consent defaults"); + let container = written + .find("") + .expect("should write the container script"); + assert!( + consent < container, + "a container may carry tags that send measurement data, so the defaults come first" + ); + } + + #[test] + fn a_container_and_a_tag_are_both_written_container_first() { + // Arrange + let settings = settings_with_google(&json!({ + "container_id": TEST_CONTAINER_ID, + "analytics_tag_id": TEST_TAG_ID, + })); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + let container = written + .find("") + .expect("should write the container script"); + let tag = written + .find("") + .expect("should write the tag snippet"); + assert!( + container < tag, + "Tag Manager asks to be as high in the head as possible" + ); + } + + #[test] + fn the_container_loader_names_googles_host_when_tag_manager_is_not_enabled() { + // Arrange + let settings = settings_with_google(&json!({ "container_id": TEST_CONTAINER_ID })); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + assert!( + written.contains("'https://www.googletagmanager.com/gtm.js?id='+i+dl"), + "with nothing serving the container first party, the snippet is Google's own" + ); + } + + #[test] + fn the_container_loader_names_the_first_party_path_for_the_same_container() { + // Arrange + let settings = settings_with_google_and_tag_manager( + &json!({ "container_id": TEST_CONTAINER_ID }), + &json!({ "container_id": TEST_CONTAINER_ID }), + ); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + assert!( + written.contains("'/integrations/google_tag_manager/gtm.js?id='+i+dl"), + "the container this origin serves should be fetched from this origin: {written}" + ); + } + + #[test] + fn the_container_loader_names_googles_host_for_a_different_container() { + // Arrange + // That endpoint always answers with the container it is configured + // for, so a first-party path would serve the wrong one. + let settings = settings_with_google_and_tag_manager( + &json!({ "container_id": TEST_CONTAINER_ID }), + &json!({ "container_id": "GTM-OTHER123" }), + ); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + assert!( + written.contains("'https://www.googletagmanager.com/gtm.js?id='+i+dl"), + "a container this origin does not serve stays with Google" + ); + } + + #[test] + fn the_noscript_lands_immediately_after_the_opening_body_tag_once() { + // Arrange + let settings = settings_with_google(&json!({ "container_id": TEST_CONTAINER_ID })); + let html = "Example Publisher\ +

Headline

"; + + // Act + let processed = process(&settings, html); + + // Assert + assert_eq!( + processed.matches("ns.html?id=").count(), + 1, + "the noscript iframe should be written once" + ); + let body_open = processed.find("").expect("should keep the body tag"); + let noscript = processed + .find("") + .expect("should write the noscript part"); + let headline = processed + .find("

") + .expect("should keep the page content"); + assert!( + body_open < noscript && noscript < headline, + "Google's install page puts this part immediately after the opening body tag: \ + {processed}" + ); + } + + #[test] + fn a_second_body_does_not_get_a_second_noscript() { + // Arrange + // Template fragments sometimes carry more than one body element. + let settings = settings_with_google(&json!({ "container_id": TEST_CONTAINER_ID })); + let html = "Example Publisher\ +

Headline

Second"; + + // Act + let processed = process(&settings, html); + + // Assert + assert_eq!( + processed.matches("ns.html?id=").count(), + 1, + "the noscript iframe should be written once per document" + ); + } + + #[test] + fn a_page_with_no_container_configured_is_left_alone_at_the_body() { + // Arrange + let settings = settings_with_google(&json!({ "analytics_tag_id": TEST_TAG_ID })); + let html = "Example Publisher\ +

Headline

"; + + // Act + let processed = process(&settings, html); + + // Assert + assert!( + !processed.contains("noscript"), + "nothing should be written at the body when no container is configured" + ); + } + + #[test] + fn a_page_with_no_head_gets_the_noscript_and_none_of_the_head_snippets() { + // Arrange + // Core writes the script bundle's tag at the start of the head, and + // the head snippets go straight before that tag, so a document with no + // head has nowhere to take them. + let settings = settings_with_google(&json!({ + "analytics_tag_id": TEST_TAG_ID, + "container_id": TEST_CONTAINER_ID, + })); + let html = "

Headline

"; + + // Act + let processed = process(&settings, html); + + // Assert + for snippet in ["gtag('consent'", "gtag/js", "gtm.js"] { + assert!( + !processed.contains(snippet), + "nothing naming `{snippet}` should be written without a head: {processed}" + ); + } + assert_eq!( + processed.matches("ns.html?id=").count(), + 1, + "the noscript iframe should still be written once: {processed}" + ); + assert!( + processed.contains(""), + "the noscript iframe should follow the opening body tag: {processed}" + ); + } + + /// Settings naming all three ids, so every loader would be written. + fn settings_with_every_id() -> Settings { + settings_with_google(&json!({ + "analytics_tag_id": TEST_TAG_ID, + "container_id": TEST_CONTAINER_ID, + "adsense_client_id": TEST_ADSENSE_CLIENT_ID, + })) + } + + #[test] + fn a_reader_who_has_not_allowed_storage_gets_the_consent_defaults_alone() { + // Arrange + // Every permission but storage on the device, which each loader and + // the noscript iframe need. + let settings = settings_with_every_id(); + let all_but_storage: PermissionSet = Permission::all() + .filter(|permission| *permission != Permission::StoreOnDevice) + .collect(); + + // Act + let plan = plan_for(&settings, &PermissionState::new(all_but_storage)); + + // Assert + assert_eq!( + plan.head_inserts, + vec![consent_defaults_snippet(&ConsentDefaults::default())], + "the consent defaults should be the only head snippet" + ); + assert!( + plan.body_open_inserts.is_empty(), + "no noscript iframe should be written: {:?}", + plan.body_open_inserts + ); + } + + #[test] + fn a_reader_who_allows_storage_gets_every_configured_loader() { + // Arrange + let settings = settings_with_every_id(); + + // Act + let plan = plan_for(&settings, &storage_only_permissions()); + + // Assert + let head = plan.head_inserts.join(""); + for loader in ["gtm.js", "gtag/js", "adsbygoogle.js"] { + assert!( + head.contains(loader), + "the head should name `{loader}`: {head}" + ); + } + assert_eq!( + plan.body_open_inserts, + vec![container_noscript_snippet(TEST_CONTAINER_ID)], + "the noscript iframe should be written" + ); + } + + #[test] + fn no_loader_is_written_where_no_permission_state_reached_the_middleware() { + // Arrange + // A preparation whose context carries no permission state, and a + // document nothing was prepared for. + let settings = settings_with_every_id(); + let document_state = IntegrationDocumentState::default(); + + // Act + let unresolved = plan_with(&settings, &ModuleContext::new(request(REQUEST_PATH))); + let context = middleware_test_support::context(MiddlewarePhase::Serve, &document_state); + let unprepared = page_tag_for(&settings).create(&context); + + // Assert + for (case, head, body) in [ + ( + "no permission state", + unresolved.head_inserts, + unresolved.body_open_inserts, + ), + ( + "nothing prepared", + unprepared.head_inserts, + unprepared.body_open_inserts, + ), + ] { + assert_eq!( + head, + vec![consent_defaults_snippet(&ConsentDefaults::default())], + "{case}: the consent defaults should be the only head snippet" + ); + assert!(body.is_empty(), "{case}: no noscript iframe: {body:?}"); + } + } + + #[test] + fn a_page_for_a_reader_who_has_not_agreed_carries_no_google_loader() { + // Arrange + // Under the repository's sample policy, a reader in Germany whose + // request carries no signal is still waiting to allow storage on the + // device, and a reader in California who sends an opt-out, such as + // Global Privacy Control, has it revoked. The page carries its own + // loader for the configured tag as well. + let settings = settings_with_every_id(); + let policy = PermissionMaps::sample(); + let readers = [ + ("not yet agreed", policy.baseline(Some("DE"), None)), + ( + "opted out", + policy.resolve_with(Some("US"), Some("CA"), |permission, _| { + if policy.signals().opt_out_revokes(permission) { + ConsentSignal::Revoke + } else { + ConsentSignal::Neutral + } + }), + ), + ]; + + for (reader, permissions) in readers { + // Act + let processed = process_for(&settings, HEAD_WITH_ITS_OWN_TAG, &[], &permissions); + + // Assert + assert_eq!( + processed.matches("gtag('consent', 'default'").count(), + 1, + "{reader}: the consent defaults should be written: {processed}" + ); + for loader in ["gtag/js", "gtm.js", "adsbygoogle.js", "ns.html"] { + assert!( + !processed.contains(loader), + "{reader}: nothing naming `{loader}` should reach the page: {processed}" + ); + } + } + } +} diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 5c5af69369..5e5d65bdd3 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -191,6 +191,13 @@ pub fn seam_probe_head_marker(country: &str) -> String { format!("") } +/// The markup the probe's middleware writes immediately after the opening +/// body tag, so a test can see that place is reached. +#[must_use] +pub fn seam_probe_body_marker() -> String { + "".to_owned() +} + /// The word the probe's middleware swaps for the country, in the text of a /// script that carries `data-seam-probe`. pub const SEAM_PROBE_SCRIPT_WORD: &str = "SEAM_PROBE_COUNTRY"; @@ -229,6 +236,7 @@ impl Middleware for SeamProbeMiddleware { fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { MiddlewareAction { head_inserts: vec![seam_probe_head_marker(&self.country)], + body_open_inserts: vec![seam_probe_body_marker()], element_handlers: vec![Box::new(AttributeRewrite::matching( "a[data-seam-probe]", "href", @@ -1170,6 +1178,15 @@ mod tests { 1, "should mark the head once, with the country the probe is set to: {page}" ); + assert_eq!( + page.matches(&seam_probe_body_marker()).count(), + 1, + "should mark the body once: {page}" + ); + assert!( + page.contains(&format!("{}", seam_probe_body_marker())), + "should mark immediately after the opening body tag: {page}" + ); assert!( page.contains(&format!( "probe" diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index bb81eef875..6cf6fda67d 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -2875,9 +2875,13 @@ mod tests { vec![( "text/html".to_owned(), false, - vec!["bot-protection.datadome.tag".to_owned()], + vec![ + "bot-protection.datadome.tag".to_owned(), + "ad-tag.google.loader".to_owned(), + ], )], - "should run the one serve middleware the selected modules supply" + "should run the serve middleware the selected modules supply, in the order the \ + stock modules register" ); trusted_server_core::settings::Settings::from_toml( &draft diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index b19b41700f..552dc99430 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -482,12 +482,32 @@ fn middleware_content_handlers( handlers } +/// The handler that writes `inserts` immediately after the opening `` +/// tag, once, or `None` when there is nothing to write, so a document whose +/// middleware write nothing there gets no handler. +fn body_open_handler(inserts: &[String]) -> Option { + if inserts.is_empty() { + return None; + } + let markup = inserts.concat(); + let written = Cell::new(false); + Some(element!("body", move |el| { + // Once per document, because a template fragment can carry a second + // ``. + if !written.replace(true) { + el.prepend(&markup, ContentType::Html); + } + Ok(()) + })) +} + /// The processor behind [`create_serve_processor`], carrying out `plan` and /// nothing else. fn build_serve_processor(plan: MiddlewarePlan) -> HtmlWithStreamingProcessors { let MiddlewarePlan { head_inserts, after_bundle_inserts, + body_open_inserts, element_handlers, text_handlers, processors, @@ -495,6 +515,9 @@ fn build_serve_processor(plan: MiddlewarePlan) -> HtmlWithStreamingProcessors { response_headers: _, } = plan; let mut element_content_handlers: Vec = Vec::new(); + if let Some(handler) = body_open_handler(&body_open_inserts) { + element_content_handlers.push(handler); + } if !head_inserts.is_empty() || !after_bundle_inserts.is_empty() { let before = head_inserts.concat(); let after = after_bundle_inserts.concat(); @@ -568,6 +591,7 @@ fn build_html_processor( let MiddlewarePlan { head_inserts: middleware_head_inserts, after_bundle_inserts: middleware_after_bundle_inserts, + body_open_inserts: middleware_body_open_inserts, element_handlers: middleware_element_handlers, text_handlers: middleware_text_handlers, processors: middleware_processors, @@ -891,6 +915,9 @@ fn build_html_processor( // Registered after core's own handlers, so an element handler judges an // address as it will be served. + if let Some(handler) = body_open_handler(&middleware_body_open_inserts) { + element_content_handlers.push(handler); + } element_content_handlers.extend(middleware_content_handlers( middleware_element_handlers, middleware_text_handlers, @@ -1250,6 +1277,35 @@ mod tests { assert!(!untold.contains(PUBLIC_PREFIX_ATTRIBUTE), "{untold}"); } + /// A body-open insert is written immediately after the opening body tag, + /// once, however many body tags a fragment carries. The fetch processor's + /// handler is the same function, and the seam probe's page test drives it. + #[test] + fn a_body_open_insert_is_written_once_after_the_opening_body_tag() { + let plan = MiddlewarePlan { + body_open_inserts: vec!["

first

".to_owned(), "

second

".to_owned()], + ..MiddlewarePlan::default() + }; + + let output = build_serve_processor(plan) + .process_chunk( + b"

page

", + true, + ) + .expect("should process HTML"); + + let out = String::from_utf8(output).expect("should be UTF-8"); + assert!( + out.contains("

first

second

page

"), + "the inserts follow the opening body tag in order: {out}" + ); + assert_eq!( + out.matches("

first

").count(), + 1, + "once per document: {out}" + ); + } + #[test] fn a_registration_marks_only_the_bundle_it_asks_to_mark() { use crate::integrations::registry_test_support::tag_fixture as tag; diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index 767ac92bf1..fbf8b1c726 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -295,6 +295,11 @@ pub struct MiddlewareAction { /// deferred one, for a script that needs the bundle to have run and has /// to run before the page's own scripts. pub after_bundle_inserts: Vec, + /// Markup to write immediately after the opening `` tag, once per + /// document, in the order given, for a vendor's installation whose second + /// part goes there and nowhere else. A document with no `` gets + /// none, and a second `` in a template fragment gets nothing. + pub body_open_inserts: Vec, /// Decisions about elements. pub element_handlers: Vec>, /// Decisions about the text inside elements. @@ -327,6 +332,7 @@ impl MiddlewareAction { pub fn is_stream_only(&self) -> bool { self.head_inserts.is_empty() && self.after_bundle_inserts.is_empty() + && self.body_open_inserts.is_empty() && self.element_handlers.is_empty() && self.text_handlers.is_empty() } @@ -361,6 +367,7 @@ impl fmt::Debug for MiddlewareAction { .debug_struct("MiddlewareAction") .field("head_inserts", &self.head_inserts) .field("after_bundle_inserts", &self.after_bundle_inserts) + .field("body_open_inserts", &self.body_open_inserts) .field( "element_handlers", &selectors_of_elements(&self.element_handlers), @@ -937,6 +944,7 @@ impl MiddlewareChain { plan.head_inserts.extend(action.head_inserts); plan.after_bundle_inserts .extend(action.after_bundle_inserts); + plan.body_open_inserts.extend(action.body_open_inserts); plan.element_handlers.extend(action.element_handlers); plan.text_handlers.extend(action.text_handlers); plan.processors.extend(action.stream); @@ -955,6 +963,9 @@ pub struct MiddlewarePlan { /// Markup for straight after the main script bundle, in the order it is /// written. pub after_bundle_inserts: Vec, + /// Markup for immediately after the opening `` tag, in the order it + /// is written. + pub body_open_inserts: Vec, /// Element handlers, in the order the middleware were named. Two that /// match one element are both asked, in this order. pub element_handlers: Vec>, @@ -973,6 +984,7 @@ impl MiddlewarePlan { pub fn is_empty(&self) -> bool { self.head_inserts.is_empty() && self.after_bundle_inserts.is_empty() + && self.body_open_inserts.is_empty() && self.element_handlers.is_empty() && self.text_handlers.is_empty() && self.processors.is_empty() @@ -986,6 +998,7 @@ impl fmt::Debug for MiddlewarePlan { .debug_struct("MiddlewarePlan") .field("head_inserts", &self.head_inserts) .field("after_bundle_inserts", &self.after_bundle_inserts) + .field("body_open_inserts", &self.body_open_inserts) .field( "element_handlers", &selectors_of_elements(&self.element_handlers), diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml index 9ba7a7b20e..5520109c1e 100644 --- a/crates/trusted-server-modules/Cargo.toml +++ b/crates/trusted-server-modules/Cargo.toml @@ -30,6 +30,7 @@ trusted-server-cmp-sourcepoint = { workspace = true } trusted-server-core = { workspace = true } trusted-server-framework-nextjs = { workspace = true } trusted-server-identity-lockr = { workspace = true } +trusted-server-tag-google-tag = { workspace = true } trusted-server-tag-google-tag-manager = { workspace = true } trusted-server-testing-testlight = { workspace = true } diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 5970abda4f..218ec1804f 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -37,6 +37,7 @@ pub fn builders() -> Vec { trusted_server_cmp_inmobi::builder(), trusted_server_cmp_sourcepoint::builder(), trusted_server_cmp_osano::builder(), + trusted_server_tag_google_tag::builder(), trusted_server_tag_google_tag_manager::builder(), trusted_server_bot_protection_datadome::builder(), trusted_server_ad_tag_google::builder(), @@ -112,6 +113,7 @@ pub fn middleware() -> Vec { use trusted_server_core::service_version; use trusted_server_framework_nextjs as nextjs; use trusted_server_identity_lockr as lockr; + use trusted_server_tag_google_tag as google_tag; use trusted_server_tag_google_tag_manager as google_tag_manager; use trusted_server_testing_testlight as testlight; @@ -132,6 +134,7 @@ pub fn middleware() -> Vec { row(didomi::builder().id(), Fetch, didomi::MODULE), row(inmobi::builder().id(), Fetch, inmobi::MODULE), row(sourcepoint::builder().id(), Fetch, sourcepoint::MODULE), + row(google_tag::builder().id(), Serve, google_tag::MODULE), row( google_tag_manager::builder().id(), Fetch, @@ -140,6 +143,7 @@ pub fn middleware() -> Vec { row(datadome::builder().id(), Fetch, datadome::MODULE), row(datadome::builder().id(), Serve, datadome::TAG_MIDDLEWARE), row(google::builder().id(), Fetch, google::MODULE), + row(google::builder().id(), Serve, google::LOADER_MIDDLEWARE), row( google::diagnostics::builder().id(), Serve, @@ -207,6 +211,7 @@ mod tests { 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/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"), include_str!("../../ad-tag/google/src/fixtures/page-change.settings.toml"), @@ -487,6 +492,7 @@ middleware = [\"robots_txt\"] "cmp.inmobi", "cmp.sourcepoint", "cmp.osano", + "tag.google-tag", "tag.google-tag-manager", "bot-protection.datadome", "ad-tag.google", diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3e14458e39..b0a80990ab 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1897,6 +1897,7 @@ validation knows is listed below. | ----------------------------- | ------------------------------------------------------------------ | | `[bot-protection.datadome]` | [DataDome](/guide/integrations/datadome) | | `[cmp.didomi]` | [Didomi](/guide/integrations/didomi) | +| `[tag.google-tag]` | The Google tag, written on the page from the settings | | `[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) | @@ -1924,7 +1925,7 @@ module = "nextjs" ``` The modules this repository ships, by section, are `[cmp]` `didomi`, -`inmobi`, `sourcepoint` and `osano`; `[tag]` `google-tag-manager`; `[ad-tag]` `google` +`inmobi`, `sourcepoint` and `osano`; `[tag]` `google-tag` and `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 @@ -2053,25 +2054,27 @@ consent management platform's middleware runs in either phase, as the publisher chooses, and is listed under `[[fetch]]`, which is the phase a generated configuration places it in. -| Middleware | Phase | What it changes | -| ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auction.prebid` | `[[fetch]]` | Writes `window.__tsjs_prebid` and the tag that loads the bundle into the head, and removes an element whose `src` or `href` matches `script_patterns` | -| `js_asset_proxy` | `[[fetch]]` | Points each configured script at its first-party path and removes one that is blocked. It is asked about an address as the middleware named before it left it, so name it ahead of one that moves the same script | -| `testing.testlight` | `[[fetch]]` | Points a `src` or `href` that names `testlight.js` at the shim, when `rewrite_scripts` is set | -| `framework.nextjs` | `[[fetch]]` | Moves the origin's address to the publisher's in the data Next.js writes into a page, being the `__NEXT_DATA__` script and the React Server Components payload scripts | -| `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 | -| `bot-protection.datadome.tag` | `[[serve]]` | Writes DataDome's client tag into the head, unless the request filter marked the request, `inject_client_side_tag` is off or no `client_side_key` is set | -| `ad-tag.google` | `[[fetch]]` | Writes the `tsjs.adInit` bootstrap into the head and, when `rewrite_script` is set, points a `src` or `href` that is the GPT script's address at `/integrations/gpt/script` | -| `ad-tag.google.diagnostics` | `[[serve]]` | For a request that activated diagnostics, writes the bootstrap ahead of the script bundle and the tag that loads the diagnostics module straight after it | -| `ads-txt.sellers` | `[[fetch]]` | Writes the lines `[ads-txt.sellers]` gives at the end of the publisher's `ads.txt`, on `text/plain` alone | -| `robots_txt` | `[[fetch]]` | Makes the `robots.txt` that `[robots-txt]` describes, in place of whatever the origin answers, on `text/plain` and at `/robots.txt` alone | -| `service_version` | `[[serve]]` | Writes `X-TS-Version`, the git version the build compiled in, and `X-TS-Service-Version`, the platform's version number of the service, when each is known. Core supplies it, so no section selects it | +| Middleware | Phase | What it changes | +| ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auction.prebid` | `[[fetch]]` | Writes `window.__tsjs_prebid` and the tag that loads the bundle into the head, and removes an element whose `src` or `href` matches `script_patterns` | +| `js_asset_proxy` | `[[fetch]]` | Points each configured script at its first-party path and removes one that is blocked. It is asked about an address as the middleware named before it left it, so name it ahead of one that moves the same script | +| `testing.testlight` | `[[fetch]]` | Points a `src` or `href` that names `testlight.js` at the shim, when `rewrite_scripts` is set | +| `framework.nextjs` | `[[fetch]]` | Moves the origin's address to the publisher's in the data Next.js writes into a page, being the `__NEXT_DATA__` script and the React Server Components payload scripts | +| `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` | `[[serve]]` | Writes the consent mode defaults in the head before the script bundle's tag. For a reader whose permissions allow storage on the device, it also writes Tag Manager's container script, the gtag.js snippet and the AdSense loader after them, and Tag Manager's noscript iframe after the opening body tag. Removes the page's own loader for the configured tag | +| `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 | +| `bot-protection.datadome.tag` | `[[serve]]` | Writes DataDome's client tag into the head, unless the request filter marked the request, `inject_client_side_tag` is off or no `client_side_key` is set | +| `ad-tag.google` | `[[fetch]]` | Writes the `tsjs.adInit` bootstrap into the head and, when `rewrite_script` is set, points a `src` or `href` that is the GPT script's address at `/integrations/gpt/script` | +| `ad-tag.google.loader` | `[[serve]]` | Writes the loader tag into the head, after the bootstrap, when `write_loader` is set, for a reader whose permissions allow storage on the device | +| `ad-tag.google.diagnostics` | `[[serve]]` | For a request that activated diagnostics, writes the bootstrap ahead of the script bundle and the tag that loads the diagnostics module straight after it | +| `ads-txt.sellers` | `[[fetch]]` | Writes the lines `[ads-txt.sellers]` gives at the end of the publisher's `ads.txt`, on `text/plain` alone | +| `robots_txt` | `[[fetch]]` | Makes the `robots.txt` that `[robots-txt]` describes, in place of whatever the origin answers, on `text/plain` and at `/robots.txt` alone | +| `service_version` | `[[serve]]` | Writes `X-TS-Version`, the git version the build compiled in, and `X-TS-Service-Version`, the platform's version number of the service, when each is known. Core supplies it, so no section selects it | An entry that could not do what it says refuses the configuration, both when a deployment is validated and when the settings load. That is a media type @@ -2163,6 +2166,21 @@ fetch middleware writes ahead of the script bundle, and so is naming it for one page in both phases, which would write it twice. See [InMobi Choice](/guide/integrations/inmobi) for the tag's parts and what the module keeps to. +### Google Tag Integration + +**Section**: `[tag.google-tag]` + +| Field | Type | Default | Contract | +| ---------------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `analytics_tag_id` | String | Absent | A `G-`, `GT-`, `GTM-`, `AW-`, `DC-`, `MC-` or `UA-` tag id | +| `container_id` | String | Absent | `GTM-` followed by 4 to 20 capitals or digits | +| `adsense_client_id` | String | Absent | `ca-pub-` followed by sixteen digits | +| `consent_defaults.*` | String | `denied` | `ad_storage`, `ad_user_data`, `ad_personalization` and `analytics_storage`, each `denied` or `granted` | +| `consent_defaults.wait_for_update` | Integer | `500` | Milliseconds the tag waits for a consent management platform's update before it fires with the defaults, at most `60000` | +| `consent_defaults.region` | Array | Absent | Country or region codes the defaults apply to, such as `GB` or `US-CA`, everywhere when absent. Every other visitor gets all four denied | + +At least one of the three ids is required, because a table naming none asks the module to write nothing. The module writes, in the head immediately before the script bundle's tag and so after whatever a `[[fetch]]` middleware wrote there, the consent mode defaults, then the container script, then the tag snippet, then the AdSense loader, each copied from Google's documentation, and Tag Manager's noscript iframe immediately after the opening body tag. Every reader gets the consent defaults, and only a reader whose permissions allow storage on the device (`necessary.operations.storage`) gets the rest, because each of them stores and reads Google's identifiers there. Where the Tag Manager module serves the same tag or container, the loader names the first-party path. A page that also loads gtag.js for the configured tag has that loader removed for every reader. An inline configuration call in the page is left alone and still sends a second page view. + ### Google Tag Manager Integration **Section**: `[tag.google-tag-manager]` @@ -2187,6 +2205,9 @@ See [Google Tag Manager](/guide/integrations/google_tag_manager). | `cache_ttl_seconds` | Integer | `3600` | `60..=86400` seconds | | `rewrite_script` | Boolean | `true` | Rewrite matching GPT script URLs | | `slim_prebid_url` | String or null | `null` | Optional tsjs-prebid bundle loaded after `window.load` | +| `write_loader` | Boolean | `false` | Write the loader tag, for a page that carries none | + +`write_loader` takes effect where a `[[serve]]` entry names `ad-tag.google.loader`, which writes the loader tag after the bootstrap and only for a reader whose permissions allow storage on the device (`necessary.operations.storage`), because GPT loaded from Google's standard address may use the device's storage at any time. See [GPT](/guide/integrations/gpt). diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 639ac7e933..c94ac7ab38 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -604,13 +604,14 @@ 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 any of five things. -| Field | What it does | -| ---------------------- | -------------------------------------------------------------------------------------------------------- | -| `head_inserts` | Writes markup in ``, ahead of the script bundle | -| `after_bundle_inserts` | Writes markup straight after the script bundle, for a script that needs the bundle to have run | -| `element_handlers` | For the elements a CSS selector matches, keeps the element, replaces one attribute's value or removes it | -| `text_handlers` | For the text inside the elements a CSS selector matches, keeps a chunk, replaces it or removes it | -| `stream` | Runs a stream processor over the document the handlers left | +| Field | What it does | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `head_inserts` | Writes markup in ``, ahead of the script bundle | +| `after_bundle_inserts` | Writes markup straight after the script bundle, for a script that needs the bundle to have run | +| `body_open_inserts` | Writes markup immediately after the opening body tag, once per document, for an installation whose second part goes there | +| `element_handlers` | For the elements a CSS selector matches, keeps the element, replaces one attribute's value or removes it | +| `text_handlers` | For the text inside the elements a CSS selector matches, keeps a chunk, replaces it or removes it | +| `stream` | Runs a stream processor over the document the handlers left | A middleware can rely on these rules. @@ -746,10 +747,12 @@ never stored as one, and an HTML response with a body is sent `private, no-store`. So a module leaves a value only for a request it will act on, and an ordinary request stays on the shared path. -A serve middleware has two places to write markup. `head_inserts` writes -straight before the script bundle, and `after_bundle_inserts` writes straight +A serve middleware has three places to write markup. `head_inserts` writes +straight before the script bundle, `after_bundle_inserts` writes straight after it, for a script that needs the bundle to have run and has to run -before the page's own scripts. +before the page's own scripts, and `body_open_inserts` writes immediately +after the opening body tag, once per document, for an installation whose +second part goes there and nowhere else. ### A module's own secrets diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md index 2196c3da49..a493f8cd81 100644 --- a/docs/guide/integrations/gpt.md +++ b/docs/guide/integrations/gpt.md @@ -59,6 +59,7 @@ gam_attribution_enabled = false script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true +write_loader = false ``` ### Configuration Options @@ -69,6 +70,9 @@ rewrite_script = true | `script_url` | string | No | `https://securepubads.g.doubleclick.net/tag/js/gpt.js` | URL for the GPT bootstrap script | | `cache_ttl_seconds` | integer | No | `3600` | Cache TTL for proxied scripts (60--86400s) | | `rewrite_script` | boolean | No | `true` | Whether to rewrite GPT script URLs in HTML | +| `write_loader` | boolean | No | `false` | Write the loader tag into the head, for a page that carries none | + +The serve middleware `ad-tag.google.loader` writes the loader tag, so a deployment that sets `write_loader` names that middleware in a `[[serve]]` entry. The middleware writes the tag only for a reader whose permissions allow storage on the device (`necessary.operations.storage`), because GPT loaded from Google's standard address may use the device's storage at any time. The environment override `TRUSTED_SERVER__AD-TAG__GOOGLE__GAM_ATTRIBUTION_ENABLED` works only when diff --git a/scripts/clippy-wasm-clock/clippy.toml b/scripts/clippy-wasm-clock/clippy.toml index 1a1070e00c..1a36cc9869 100644 --- a/scripts/clippy-wasm-clock/clippy.toml +++ b/scripts/clippy-wasm-clock/clippy.toml @@ -6,3 +6,7 @@ disallowed-methods = [ { path = "std::time::SystemTime::now", reason = "panics on wasm32-unknown-unknown; use `web_time::SystemTime::now`" }, { path = "std::time::SystemTime::elapsed", reason = "calls `SystemTime::now`, which panics on wasm32-unknown-unknown; use `web_time::SystemTime`" }, ] + +# This config replaces the workspace one for that run, so the words the +# workspace lets documentation carry without backticks are listed here too. +doc-valid-idents = ["AdSense", ".."] diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 808aebe0fc..7f96df6ae5 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -385,7 +385,13 @@ pull_sync_concurrency = 3 # [[serve]] # media_type = "text/html" -# middleware = ["bot-protection.datadome.tag", "ad-tag.google.diagnostics", "service_version"] +# middleware = [ +# "tag.google-tag", +# "bot-protection.datadome.tag", +# "ad-tag.google.loader", +# "ad-tag.google.diagnostics", +# "service_version", +# ] # First-party proxying of named third-party scripts. Uncomment the # `modules` line under [proxy] below to switch it on. @@ -901,7 +907,9 @@ auction_timeout_ms = 500 # overlay can only replace a leaf the document already has, and it names a # section as written, so the variable for this leaf is # TRUSTED_SERVER__AD-TAG__GOOGLE__GAM_ATTRIBUTION_ENABLED, which a shell's -# `export` cannot set. +# `export` cannot set. `write_loader` writes the loader tag, for a page that +# carries none, where a [[serve]] entry names "ad-tag.google.loader", and +# only for a reader whose permissions allow storage on the device. # [ad-tag] # modules = ["google"] @@ -910,12 +918,27 @@ auction_timeout_ms = 500 # script_url = "https://ads.example.com/gpt.js" # cache_ttl_seconds = 3600 # rewrite_script = true +# write_loader = false # GPT runtime diagnostics browser overlay. Selected manually rather than by # `ts audit`, it serves a diagnostics module gated behind an activation query # param and a session cookie. It has nothing to set, so adding # "google.diagnostics" to [ad-tag] modules is the whole configuration. +# Google's tag, Tag Manager container and AdSense loader written on the page +# from these settings, for a publisher whose pages cannot carry them. The +# consent mode defaults are written first, and the four consent types Google +# requires for its own services default to denied. The loaders and the +# noscript iframe reach only a reader whose permissions allow storage on the +# device. +# Name "google-tag" in [tag] modules and "tag.google-tag" in a [[serve]] entry +# for "text/html". The tag and container ids below are invalid placeholders on +# purpose, so the table is refused until real ids replace them. +# [tag.google-tag] +# analytics_tag_id = "G-REPLACE_ME" +# container_id = "GTM-REPLACE-ME" +# adsense_client_id = "ca-pub-0000000000000000" + # Google Tag Manager first-party proxy. `ts audit` selects "google-tag-manager" # in [tag] and writes this table with the container_id it finds when it # detects GTM.