From f801d462fb807c8961988baf6c51cb709a1d19aa Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 11 Oct 2026 19:39:04 +0100 Subject: [PATCH 01/17] A middleware may write immediately after the opening body tag `MiddlewareAction` gains `body_open_inserts`, markup written once per document immediately after the opening `` tag, in the order given, beside the places it had for the head and for straight after the script bundle. A vendor's two-part installation may put its second part there and nowhere else, as Tag Manager's noscript frame is, and until now a middleware had no way to reach that place. A document with no body gets none, and a second body tag in a template fragment gets nothing. --- CHANGELOG.md | 1 + crates/testing/seam-probe/src/lib.rs | 17 ++++++ .../trusted-server-core/src/html_processor.rs | 56 +++++++++++++++++++ crates/trusted-server-core/src/middleware.rs | 13 +++++ docs/guide/integration-guide.md | 23 ++++---- 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80a98439d..8eeaa35ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping. A slot whose formats name no bound behaves as before. - A device module may answer the confidence that a person is looking at the page, `DeviceSignals::human_confidence`, which nothing in core gates on and which stays `Unknown` for every module that does not score the request, and may tell a bidder what the device is through `DeviceModule::advertising_attributes`, which reaches the bid request's device object as its type, make, model, operating system and screen. A module that resolves nothing adds no device object. - A prefix module, selected by `[prefix] module`, gives the start of every name the browser holds for a publisher and of every key the publisher's entries take in a shared store. Core's own module gives `ts` and `ts.` and runs when nothing is selected, so a default deployment's names are unchanged. The page's bundle tag tells the client script the public prefix as `data-ts-public-prefix`, and a template stored under one prefix is never served under another. diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 784c2dd644..8fd592c2cd 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -160,6 +160,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"; @@ -186,6 +193,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", @@ -1098,6 +1106,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-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index ff5d090d6b..410695074a 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -446,17 +446,40 @@ 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, } = 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(); @@ -502,6 +525,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, @@ -834,6 +858,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, @@ -1192,6 +1219,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 16759bd4c2..ec70416a56 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -269,6 +269,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. @@ -296,6 +301,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() } @@ -325,6 +331,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), @@ -805,6 +812,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); @@ -822,6 +830,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>, @@ -837,6 +848,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() @@ -849,6 +861,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/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 09abf00378..53466b53ef 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -535,13 +535,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. @@ -686,10 +687,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 From ba6d8b39960f6c856de4d7c8fc94e8c5f4d375ed Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 11 Oct 2026 19:42:35 +0100 Subject: [PATCH 02/17] A module writes Google's tags on the page from the settings A publisher whose page templates forbid third-party scripts cannot put a Google tag on the page, so it has to come from the proxy, and nothing did that: the Tag Manager module re-serves a tag the page already carries, which needs a page that already loads Google. `crates/tag/google-tag`, selected by `[tag] modules` with `[tag.google-tag]`, writes Google's own snippets. At the start of head, the consent mode defaults, then Tag Manager's container script, then the gtag.js snippet, then the AdSense loader. Immediately after the opening body tag, Tag Manager's noscript iframe. Each is copied from Google's documentation rather than paraphrased and carries the page it came from, so a reader can compare the two. Consent defaults come first because Google's consent guide requires the default command before anything that sends measurement data, and all four types default to denied because no Google identifier may be stored before a permission signal has been processed. Where each loader points is settled at startup, because head and body insertions never reach the attribute rewriters. When the Tag Manager module serves the same tag or container, the loader names the first-party path, otherwise Google's own address, and the Tag Manager crate offers the two paths and the two id shapes it accepts so the two modules cannot disagree about an id. The noscript iframe always names Google, because `ns.html` has no first-party route behind it. A page also loading gtag.js for the same tag has that duplicate loader removed, which is why the module registers before Tag Manager in the stock list, since a removal short-circuits the rewriter chain. An inline configuration call in the publisher's own page is not removed and still sends a second page view, which the guide says plainly, because removing it would mean buffering every response. --- .cargo/config.toml | 12 +- AGENTS.md | 2 +- CHANGELOG.md | 1 + Cargo.lock | 16 + Cargo.toml | 2 + clippy.toml | 2 + crates/tag/google-tag-manager/src/lib.rs | 67 + crates/tag/google-tag/Cargo.toml | 35 + .../src/fixtures/head-with-its-own-tag.html | 32 + .../src/fixtures/head-with-tag-manager.html | 40 + .../src/fixtures/page-change.settings.toml | 9 + crates/tag/google-tag/src/lib.rs | 1366 +++++++++++++++++ crates/trusted-server-modules/Cargo.toml | 1 + crates/trusted-server-modules/src/lib.rs | 7 + docs/guide/configuration.md | 53 +- trusted-server.example.toml | 14 +- 16 files changed, 1632 insertions(+), 27 deletions(-) create mode 100644 crates/tag/google-tag/Cargo.toml create mode 100644 crates/tag/google-tag/src/fixtures/head-with-its-own-tag.html create mode 100644 crates/tag/google-tag/src/fixtures/head-with-tag-manager.html create mode 100644 crates/tag/google-tag/src/fixtures/page-change.settings.toml create mode 100644 crates/tag/google-tag/src/lib.rs 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 fe9e071677..37301662e6 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eeaa35ae9..ebf5dd6a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader at the start of head and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. The consent defaults come first and every type defaults to denied. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed. - A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping. A slot whose formats name no bound behaves as before. - A device module may answer the confidence that a person is looking at the page, `DeviceSignals::human_confidence`, which nothing in core gates on and which stays `Unknown` for every module that does not score the request, and may tell a bidder what the device is through `DeviceModule::advertising_attributes`, which reaches the bid request's device object as its type, make, model, operating system and screen. A module that resolves nothing adds no device object. diff --git a/Cargo.lock b/Cargo.lock index be3ebcbdb1..750bfbcd05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7135,6 +7135,7 @@ dependencies = [ "trusted-server-framework-nextjs", "trusted-server-identity-lockr", "trusted-server-loading-cache-51degrees", + "trusted-server-tag-google-tag", "trusted-server-tag-google-tag-manager", "trusted-server-testing-testlight", ] @@ -7191,6 +7192,21 @@ dependencies = [ "trusted-server-core", ] +[[package]] +name = "trusted-server-tag-google-tag" +version = "0.1.0" +dependencies = [ + "error-stack", + "log", + "regex", + "serde", + "serde_json", + "trusted-server-core", + "trusted-server-tag-google-tag-manager", + "url", + "validator", +] + [[package]] name = "trusted-server-tag-google-tag-manager" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7a4b6f451d..52b688d90e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/cmp/inmobi", "crates/ads-txt/sellers", "crates/loading-cache/51degrees", + "crates/tag/google-tag", "crates/tag/google-tag-manager", "crates/testing/testlight", "crates/framework/nextjs", @@ -171,6 +172,7 @@ trusted-server-cmp-didomi = { path = "crates/cmp/didomi" } trusted-server-cmp-inmobi = { path = "crates/cmp/inmobi" } trusted-server-ads-txt-sellers = { path = "crates/ads-txt/sellers" } trusted-server-loading-cache-51degrees = { path = "crates/loading-cache/51degrees" } +trusted-server-tag-google-tag = { path = "crates/tag/google-tag" } trusted-server-tag-google-tag-manager = { path = "crates/tag/google-tag-manager" } trusted-server-testing-testlight = { path = "crates/testing/testlight" } trusted-server-framework-nextjs = { path = "crates/framework/nextjs" } diff --git a/clippy.toml b/clippy.toml index bb3a6138d4..f2d36f7c40 100644 --- a/clippy.toml +++ b/clippy.toml @@ -14,3 +14,5 @@ allow-unwrap-in-tests = true # Keep current Trusted Server thresholds while strict lint cleanup is underway. cognitive-complexity-threshold = 30 too-many-lines-threshold = 200 +# Words Clippy would otherwise want in backticks in documentation. +doc-valid-idents = ["AdSense", ".."] diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index 699ea54073..779b86fa03 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -918,6 +918,73 @@ fn build( /// /// Returns an error when the Google Tag Manager configuration cannot be parsed or fails /// validation. +/// Whether `value` has the shape of a tag id Google serves from `gtag/js`. +/// +/// Offered so the google tag module accepts exactly the ids this one accepts. +/// Two spellings of the same rule would eventually disagree, and the pair that +/// disagreed would be a tag configured in one place and refused in the other. +#[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, offered for the same +/// reason as [`tag_id_is_well_formed`]. +#[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"))) +} + pub(crate) fn validate(settings: &Settings) -> Result> { settings .module_config::(MODULE) diff --git a/crates/tag/google-tag/Cargo.toml b/crates/tag/google-tag/Cargo.toml new file mode 100644 index 0000000000..a54ed561f2 --- /dev/null +++ b/crates/tag/google-tag/Cargo.toml @@ -0,0 +1,35 @@ +[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] +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..48254d333f --- /dev/null +++ b/crates/tag/google-tag/src/lib.rs @@ -0,0 +1,1366 @@ +//! 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. +//! +//! 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: +//! +//! - AdSense review before any ad appears: +//! +//! - The AdSense crawler and robots.txt: +//! + +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, +}; +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. +pub const GOOGLE_INTEGRATION_ID: &str = "google"; + +/// 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_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 this engine's order of events allows + /// 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", + } + } +} + +/// The four consent types Google's consent mode defines. +/// +/// All four default to denied. That is Google's own recommended starting +/// point and it is also what this engine's order of events requires, because +/// no Google identifier may be stored before a permission signal has been +/// processed. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +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, +} + +/// 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, +/// borrowed rather than repeated so the two cannot drift apart and disagree +/// about the same id. +/// +/// # 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, +/// borrowed rather than repeated for the same reason as the tag id. +/// +/// # 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. +fn consent_defaults_snippet(defaults: &ConsentDefaults) -> String { + format!( + "", + 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(), + ) +} + +/// 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 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 page tag 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 page tag 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, 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`)". 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. + fn page_inserts(&self) -> Vec { + let mut inserts = vec![consent_defaults_snippet(&self.config.consent_defaults)]; + + 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. + /// + /// 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) -> Vec { + self.config + .container_id + .as_deref() + .map(container_noscript_snippet) + .into_iter() + .collect() + } +} + +impl GoogleIntegration { + /// Drops a second loader for the tag this module already wrote. + /// + /// A page also carrying the loader for the same id would fetch the same + /// library twice, 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 the duplicate is dropped" + ); + return AttributeRewriteAction::RemoveElement; + } + + AttributeRewriteAction::Keep + } +} + +/// Writes the module's markup onto the page for each reader, and removes the +/// page's own loader for the configured tag so the library is fetched once. +/// +/// Serve only, because it adds to the page, and the removal belongs with the +/// addition it makes room for. +struct PageTag(Arc); + +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 create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + 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(), + body_open_inserts: self.0.body_inserts(), + 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. It cannot be + // left to a rewrite of the page, because `lol_html` writes injected markup + // straight out instead of feeding it back through the parser. + 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_INTEGRATION_ID) + .with_middleware(Arc::new(PageTag(integration))) + .build(), + )) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use trusted_server_core::test_support::tests::create_test_settings; + + /// 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 tag snippet exactly as + /// gives it. + /// + /// Held here rather than built from the code under test, because a golden + /// value derived from the thing it checks proves only that the code agrees + /// with itself. + 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 page tag 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 page tag"), + ) + } + + /// The head snippets the page tag writes for `settings`. + fn head_inserts_for(settings: &Settings) -> Vec { + let document_state = trusted_server_core::integrations::IntegrationDocumentState::default(); + let context = trusted_server_core::middleware::test_support::context( + MiddlewarePhase::Serve, + &document_state, + ); + page_tag_for(settings).create(&context).head_inserts + } + + /// `html` after the serve phase has run the page tag for `settings`, with + /// the Tag Manager module beside it where the settings select it. + fn process(settings: &Settings, html: &str) -> String { + use trusted_server_core::html_processor::test_support::{ + place_on_every_page, processed_page, + }; + let mut placed = settings.clone(); + place_on_every_page(&mut placed, MiddlewarePhase::Serve, &[MODULE]); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &placed, + &[builder(), google_tag_manager::builder()], + ) + .expect("should build a registry with the google modules"); + processed_page(&placed, ®istry, html, 8192) + } + + #[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}" + ); + } + + #[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 page tag 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_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" + ); + } +} diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml index 0b3671d567..2e37fab8d3 100644 --- a/crates/trusted-server-modules/Cargo.toml +++ b/crates/trusted-server-modules/Cargo.toml @@ -31,6 +31,7 @@ trusted-server-core = { workspace = true } trusted-server-framework-nextjs = { workspace = true } trusted-server-identity-lockr = { workspace = true } trusted-server-loading-cache-51degrees = { 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 dfbaadaecc..d0df49f5f3 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -36,6 +36,9 @@ pub fn builders() -> Vec { trusted_server_cmp_inmobi::builder(), trusted_server_cmp_sourcepoint::builder(), trusted_server_cmp_osano::builder(), + // Before Tag Manager, because this module removes a duplicate loader + // and a removal short-circuits the rewriter chain. + 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(), @@ -113,6 +116,7 @@ pub fn middleware() -> Vec { use trusted_server_core::robots_txt::middleware as robots_txt; 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; @@ -133,6 +137,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, @@ -205,6 +210,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"), @@ -486,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 8775065208..57020b7491 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1772,6 +1772,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) | @@ -1799,7 +1800,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 @@ -1920,24 +1921,25 @@ 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 the origin's file or where the origin has none, on `text/plain` alone | +| 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, Tag Manager's container script, the gtag.js snippet and the AdSense loader at the start of head, Tag Manager's noscript iframe after the opening body tag, and 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.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 the origin's file or where the origin has none, on `text/plain` alone | 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 @@ -2025,6 +2027,19 @@ prompt would never be written, and the entry names it before any middleware that writes a vendor's tag. See [InMobi Choice](/guide/integrations/inmobi) for the tag's parts and what the module keeps to. +### Google Tag 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` | + +At least one of the three ids is required, because a table naming none asks the module to write nothing. The module writes, at the start of head, 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. 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; 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]` diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0aae5ef355..cf377a715c 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -368,7 +368,7 @@ pull_sync_concurrency = 3 # [[serve]] # media_type = "text/html" -# middleware = ["bot-protection.datadome.tag", "ad-tag.google.diagnostics"] +# middleware = ["tag.google-tag", "bot-protection.datadome.tag", "ad-tag.google.diagnostics"] # First-party proxying of named third-party scripts. Uncomment the # `modules` line under [proxy] below to switch it on. @@ -896,6 +896,18 @@ auction_timeout_ms = 500 # 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 every type defaults to denied. +# Name "google-tag" in [tag] modules and "tag.google-tag" in a [[serve]] entry +# for "text/html". +# [tag.google-tag] +# analytics_tag_id = "G-REPLACE-ME" +# container_id = "GTM-REPLACE-ME" +# adsense_client_id = "ca-pub-0000000000000000" +# [tag.google-tag.consent_defaults] +# ad_storage = "denied" + # 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. From 17f599fc0bf774517a171c877e42338f9031bed4 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:18:15 +0100 Subject: [PATCH 03/17] The Google tag module says where its snippets land The docs said the head snippets land at the start of head, where a serve middleware writes them immediately before the script bundle's tag. The registration id is google_tag and private. The consent types are named as the four Google requires for its own services. The validate doc in the Tag Manager crate is back on validate, and unused links, repeated reasoning and the builders order comment are cut. --- CHANGELOG.md | 2 +- crates/tag/google-tag-manager/src/lib.rs | 21 +++----- crates/tag/google-tag/src/lib.rs | 64 +++++++++--------------- crates/trusted-server-modules/src/lib.rs | 2 - docs/guide/configuration.md | 40 +++++++-------- trusted-server.example.toml | 5 +- 6 files changed, 56 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0554c479..f1219f1d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader at the start of head and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. The consent defaults come first and every type defaults to denied. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed. +- A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader in the head immediately before the script bundle's tag, and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. The consent defaults come first, and the four consent types Google requires for its own services default to denied. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed. - A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, and the bundle's refresh auction offers a bidder only the sizes of the band the viewport is in. A slot whose formats name no bound behaves as before. - A device module may answer the confidence that a person is looking at the page, `DeviceSignals::human_confidence`, which nothing in core gates on and which stays `Unknown` for every module that does not score the request, and may tell a bidder what the device is through `DeviceModule::advertising_attributes`, which reaches the bid request's device object as its type, make, model, operating system and screen. A module that resolves nothing adds no device object. diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index 6b1e24badf..de36072699 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -911,25 +911,13 @@ fn build( Ok(Some(GoogleTagManagerIntegration::new(config))) } -/// Validates the Google Tag Manager configuration for deployment and reports whether -/// a section selects the integration's module. -/// -/// # Errors -/// -/// Returns an error when the Google Tag Manager configuration cannot be parsed or fails -/// validation. /// Whether `value` has the shape of a tag id Google serves from `gtag/js`. -/// -/// Offered so the google tag module accepts exactly the ids this one accepts. -/// Two spellings of the same rule would eventually disagree, and the pair that -/// disagreed would be a tag configured in one place and refused in the other. #[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, offered for the same -/// reason as [`tag_id_is_well_formed`]. +/// 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) @@ -985,6 +973,13 @@ pub fn first_party_gtm_js_path( 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. +/// +/// # Errors +/// +/// Returns an error when the Google Tag Manager configuration cannot be parsed or fails +/// validation. pub(crate) fn validate(settings: &Settings) -> Result> { settings .module_config::(MODULE) diff --git a/crates/tag/google-tag/src/lib.rs b/crates/tag/google-tag/src/lib.rs index 48254d333f..581b022e61 100644 --- a/crates/tag/google-tag/src/lib.rs +++ b/crates/tag/google-tag/src/lib.rs @@ -25,10 +25,6 @@ //! //! - The AdSense loader tag: //! -//! - AdSense review before any ad appears: -//! -//! - The AdSense crawler and robots.txt: -//! use std::rc::Rc; use std::sync::{Arc, LazyLock}; @@ -49,7 +45,7 @@ use trusted_server_tag_google_tag_manager as google_tag_manager; use validator::{Validate, ValidationError}; /// The id this integration's registration carries. -pub const GOOGLE_INTEGRATION_ID: &str = "google"; +const GOOGLE_TAG_INTEGRATION_ID: &str = "google_tag"; /// The name this module is selected by, in `[tag]`. pub const MODULE: &str = "tag.google-tag"; @@ -60,7 +56,7 @@ 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_INTEGRATION_ID, SOURCE, register, validate) + IntegrationBuilder::new(GOOGLE_TAG_INTEGRATION_ID, SOURCE, register, validate) .with_module_name(MODULE) } @@ -103,8 +99,8 @@ static ADSENSE_CLIENT_ID_PATTERN: LazyLock = LazyLock::new(|| { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ConsentState { - /// The default, and the only one this engine's order of events allows - /// before a permission signal has been read. + /// 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 @@ -122,12 +118,11 @@ impl ConsentState { } } -/// The four consent types Google's consent mode defines. +/// The four consent types Google requires for its own services. /// /// All four default to denied. That is Google's own recommended starting -/// point and it is also what this engine's order of events requires, because -/// no Google identifier may be stored before a permission signal has been -/// processed. +/// point, and no Google identifier may be stored before a permission signal +/// has been read. #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct ConsentDefaults { @@ -211,9 +206,7 @@ fn validate_at_least_one_id(config: &GoogleConfig) -> Result<(), ValidationError /// Refuses a tag id Google does not serve from `gtag/js`. /// -/// The shape is the one the Google Tag Manager integration already accepts, -/// borrowed rather than repeated so the two cannot drift apart and disagree -/// about the same id. +/// The shape is the one the Google Tag Manager integration already accepts. /// /// # Errors /// @@ -230,8 +223,7 @@ fn validate_analytics_tag_id(value: &str) -> Result<(), ValidationError> { /// Refuses a container id Google Tag Manager would not recognise. /// -/// The shape is the one the Google Tag Manager integration already accepts, -/// borrowed rather than repeated for the same reason as the tag id. +/// The shape is the one the Google Tag Manager integration already accepts. /// /// # Errors /// @@ -289,9 +281,10 @@ fn consent_defaults_snippet(defaults: &ConsentDefaults) -> String { /// /// Copied from , /// which says to place it "immediately after the opening `` HTML tag on -/// every page you want to measure". The only substitutions are the tag id -/// and, where the Google Tag Manager integration serves this tag, the loader -/// address. +/// 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\ @@ -326,7 +319,7 @@ fn container_script_snippet(container_id: &str, gtm_js_src: &str) -> String { /// 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 page tag writes it at the start of +/// 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 @@ -389,7 +382,7 @@ struct GoogleIntegration { /// 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 page tag runs on every document. + /// 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, @@ -553,9 +546,7 @@ pub fn register( }; // Where each loader points is decided once, here, because it depends on - // another module's configuration rather than on the request. It cannot be - // left to a rewrite of the page, because `lol_html` writes injected markup - // straight out instead of feeding it back through the parser. + // 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)? @@ -575,7 +566,7 @@ pub fn register( let integration = Arc::new(GoogleIntegration::new(config, gtag_js_src, gtm_js_src)); Ok(Some( - IntegrationRegistration::builder(GOOGLE_INTEGRATION_ID) + IntegrationRegistration::builder(GOOGLE_TAG_INTEGRATION_ID) .with_middleware(Arc::new(PageTag(integration))) .build(), )) @@ -595,12 +586,7 @@ mod tests { /// An invented container id. const TEST_CONTAINER_ID: &str = "GTM-EXAMPLE1"; - /// Google's tag snippet exactly as - /// gives it. - /// - /// Held here rather than built from the code under test, because a golden - /// value derived from the thing it checks proves only that the code agrees - /// with itself. + /// Google's snippet as the install page gives it. const GOOGLE_PUBLISHED_TAG_SNIPPET: &str = "\n\ \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, ) } @@ -839,6 +910,62 @@ mod tests { } } + #[test] + fn the_tag_waits_for_the_platform_s_update_before_it_fires() { + // Arrange + let settings = settings_with_google(&json!({ "analytics_tag_id": TEST_TAG_ID })); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + assert!( + written.contains("'wait_for_update': 500"), + "the defaults should tell the tag to wait for a platform's update: {written}" + ); + assert!( + !written.contains("'region'"), + "defaults scoped to no region should name none: {written}" + ); + } + + #[test] + fn a_publisher_may_scope_the_defaults_to_regions_and_set_the_wait() { + // Arrange + let settings = settings_with_google(&json!({ + "analytics_tag_id": TEST_TAG_ID, + "consent_defaults": { "wait_for_update": 2000, "region": ["GB", "US-CA"] }, + })); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + assert!(written.contains("'wait_for_update': 2000"), "{written}"); + assert!( + written.contains("'region': ['GB', 'US-CA']"), + "the defaults should name the regions as Google's command does: {written}" + ); + } + + #[test] + fn a_region_that_is_not_a_code_is_refused_by_name() { + // Arrange + let settings = settings_with_google(&json!({ + "analytics_tag_id": TEST_TAG_ID, + "consent_defaults": { "region": ["GB", "england"] }, + })); + + // Act + let refusal = refusal_for(&settings); + + // Assert + assert!( + refusal.contains("england"), + "the refusal should name the region that is not a code: {refusal}" + ); + } + #[test] fn a_publisher_may_grant_a_consent_type_up_front() { // Arrange diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c47c0bc487..7e653d20c9 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2030,12 +2030,14 @@ for the tag's parts and what the module keeps to. **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` | +| 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 | 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. 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; an inline configuration call in the page is left alone and still sends a second page view. From 8dc482ff954eb65dc909cff83d9798d8c34f3e16 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 22:54:31 +0100 Subject: [PATCH 05/17] The wasm clock lint's clippy config lets documentation name AdSense, as the workspace config does --- scripts/clippy-wasm-clock/clippy.toml | 4 ++++ 1 file changed, 4 insertions(+) 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", ".."] From 59e966e95f9904e00e8058e6dcdd93de319d7167 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 14 Oct 2026 08:51:45 +0100 Subject: [PATCH 06/17] The Google ad tag module writes the loader tag when a page carries none A page that carries no GPT loader of its own got the bootstrap, the shim and the slot handoff from this module and nothing that loads GPT, so its slots never filled. `[ad-tag.google] write_loader = true` writes the loader tag into the head, last among the module's inserts, after the shim and the bootstrap it relies on. The tag asks for the loader on the module's own route, `/integrations/gpt/script`, so the whole chain stays first party. It is off by default, because a page that already carries the loader would otherwise load it twice. The guide's two tables and the GPT page name the setting, the example settings file shows it, and the changelog says what it adds. Three tests cover the tag written when asked, written last beside the slim-Prebid address, and absent by default. --- CHANGELOG.md | 1 + crates/ad-tag/google/src/lib.rs | 62 +++++++++++++++++++++++++++++++++ docs/guide/configuration.md | 3 +- docs/guide/integrations/gpt.md | 2 ++ trusted-server.example.toml | 1 + 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb9e48f2f..ee7ca6fe44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `[ad-tag.google] write_loader` writes the loader tag into the head, last among the module's inserts and asking for the loader on the module's own route, for a page that carries no loader of its own. Off by default, because a page that already carries the loader would otherwise load it twice. - A Google tag module, `[tag] modules = ["google-tag"]` with `[tag.google-tag]`, from `crates/tag/google-tag`, which writes the consent mode defaults, Tag Manager's container script, the gtag.js snippet and the AdSense loader in the head immediately before the script bundle's tag, and Tag Manager's noscript iframe after the opening body tag, each copied from Google's documentation. The consent defaults come first, the four consent types Google requires for its own services default to denied, the tag waits `consent_defaults.wait_for_update` milliseconds (500 unless set) for a consent management platform's update before it fires with them, and `consent_defaults.region` scopes them to country or region codes. Where the Tag Manager module serves the same tag or container the loader names the first-party path, and a page's own loader for the configured tag is removed. - A middleware may write markup immediately after the opening body tag, with `body_open_inserts` on its action, once per document, beside the places it had for the head and for straight after the script bundle. - A creative-opportunity format may name `min_viewport_width` and `max_viewport_width`, and a slot with such a format asks for only the formats that fit each band of viewport widths. The slot's page data carries `viewport_sizes`, and the Google ad tag's head bootstrap and the bundle's slot definition apply it as a size mapping, and the bundle's refresh auction offers a bidder only the sizes of the band the viewport is in. A slot whose formats name no bound behaves as before. diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index 65d40a7783..8297e5a07a 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -130,6 +130,16 @@ pub struct GptConfig { /// Override via env var: `TRUSTED_SERVER__AD-TAG__GOOGLE__SLIM_PREBID_URL` #[serde(default, skip_serializing_if = "Option::is_none")] pub slim_prebid_url: Option, + + /// Whether the loader tag is written into the head, for a page that + /// carries none of its own. The tag asks for the loader on this module's + /// own route, so it is served first party like the rest of the script + /// chain. Off by default, because a page that already carries the loader + /// would otherwise load it twice. + /// + /// Override via env var: `TRUSTED_SERVER__AD-TAG__GOOGLE__WRITE_LOADER` + #[serde(default)] + pub write_loader: bool, } impl IntegrationConfig for GptConfig {} @@ -579,6 +589,14 @@ impl GptIntegration { )); } + // Last, after the shim and the bootstrap it relies on, and fetched + // from this module's route so the whole chain stays first party. + if self.config.write_loader { + scripts.push(format!( + "" + )); + } + scripts } @@ -630,6 +648,7 @@ mod tests { cache_ttl_seconds: 3600, rewrite_script: true, slim_prebid_url: None, + write_loader: false, } } @@ -1540,6 +1559,49 @@ mod tests { ); } + #[test] + fn head_inserts_write_the_loader_tag_when_asked() { + let integration = GptIntegration::new(GptConfig { + write_loader: true, + ..test_config() + }); + let inserts = integration.head_markup(); + + assert_eq!(inserts.len(), 3, "should add one head insert for the loader"); + assert_eq!( + inserts[2], + r#""#, + "should ask for the loader on this module's own route" + ); + } + + #[test] + fn head_inserts_write_the_loader_tag_last() { + let integration = GptIntegration::new(GptConfig { + slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()), + write_loader: true, + ..test_config() + }); + let inserts = integration.head_markup(); + + assert_eq!(inserts.len(), 4, "should add the loader after every other insert"); + assert!( + inserts[3].starts_with(""#, + inserts.len(), + 3, + "should add one head insert for the loader" + ); + assert_eq!( + inserts[2], r#""#, "should ask for the loader on this module's own route" ); } @@ -1584,7 +1587,11 @@ mod tests { }); let inserts = integration.head_markup(); - assert_eq!(inserts.len(), 4, "should add the loader after every other insert"); + assert_eq!( + inserts.len(), + 4, + "should add the loader after every other insert" + ); assert!( inserts[3].starts_with("" + ) +} + +/// 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 \ - }});\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(), @@ -1091,6 +1112,61 @@ mod tests { ); } + #[test] + fn a_region_setting_leaves_every_other_visitor_with_all_four_denied() { + // Arrange + // A grant scoped to one region, which reaches visitors there and no + // one else. + let settings = settings_with_google(&json!({ + "analytics_tag_id": TEST_TAG_ID, + "consent_defaults": { + "analytics_storage": "granted", + "wait_for_update": 2000, + "region": ["US"], + }, + })); + + // Act + let written = head_inserts_for(&settings).join(""); + + // Assert + // Google's guide gives the regional command first and then the one + // with no region, which covers every visitor the first does not. + let commands: Vec<&str> = written + .split("gtag('consent', 'default', {") + .skip(1) + .collect(); + assert_eq!( + commands.len(), + 2, + "should write two default commands: {written}" + ); + assert!( + commands[0].contains("'region': ['US']") + && commands[0].contains("'analytics_storage': 'granted'"), + "the regional command should carry the grant: {written}" + ); + assert!( + !commands[1].contains("'region'"), + "the second command should name no region: {written}" + ); + for consent_type in [ + "ad_storage", + "ad_user_data", + "ad_personalization", + "analytics_storage", + ] { + assert!( + commands[1].contains(&format!("'{consent_type}': 'denied'")), + "every other visitor should have {consent_type} denied: {written}" + ); + } + assert!( + commands[1].contains("'wait_for_update': 2000"), + "every other visitor should wait as long: {written}" + ); + } + #[test] fn a_region_that_is_not_a_code_is_refused_by_name() { // Arrange diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b7eed86306..3eb633afb5 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2053,14 +2053,14 @@ for the tag's parts and what the module keeps to. **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 | +| 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. From ab395cb659a56cf8bd39f43b192f1df7c6bce1f0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:19:43 +0100 Subject: [PATCH 10/17] Run a page through the Tag Manager module and the Google tag module Every page test of the Google tag module selected it alone, so the case where the Tag Manager module's fetch middleware has already pointed the page's own loader at the first-party path was simulated by a page that carried that path from the start. The new test selects both modules, names the Tag Manager middleware in a [[fetch]] entry and this module's in a [[serve]] entry, and runs a page that loads the configured tag from Google's host. The fetch phase rewrites the page's loader, the serve phase removes it and writes its own, and the page ends with one loader, on the first-party path, and none on Google's host. --- crates/tag/google-tag/src/lib.rs | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/tag/google-tag/src/lib.rs b/crates/tag/google-tag/src/lib.rs index 3f73bf2f3b..dc5363e514 100644 --- a/crates/tag/google-tag/src/lib.rs +++ b/crates/tag/google-tag/src/lib.rs @@ -1472,6 +1472,47 @@ mod tests { ); } + #[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 From 688e93a0b381234d6716d30736b6a193ec78319d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:19:55 +0100 Subject: [PATCH 11/17] Hold the Google tag module's name to its crate folder AGENTS.md says each page integration holds its name in a MODULE constant that a test in the crate holds to the crate's folder, and the other module crates have that test. This crate had none. The test compares MODULE with the name module_name! gives for the crate's folder. --- crates/tag/google-tag/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/tag/google-tag/src/lib.rs b/crates/tag/google-tag/src/lib.rs index dc5363e514..2ffd263109 100644 --- a/crates/tag/google-tag/src/lib.rs +++ b/crates/tag/google-tag/src/lib.rs @@ -935,6 +935,15 @@ mod tests { 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 From d5df2d69658ad32dc20aa082bbb28012151e6bb0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:20:11 +0100 Subject: [PATCH 12/17] Test the Google tag module on a document with no head Core writes the script bundle's tag at the start of the head, and a serve middleware's head markup goes straight before that tag, so a document with no head gets none of the module's head snippets, while the noscript iframe still follows the opening body tag. Nothing tested that. The new test runs such a page with a tag and a container configured, and checks that no consent default, tag snippet or container script is written and that the noscript iframe is written once, immediately after the opening body tag. --- crates/tag/google-tag/src/lib.rs | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/tag/google-tag/src/lib.rs b/crates/tag/google-tag/src/lib.rs index 2ffd263109..bfd049f4ad 100644 --- a/crates/tag/google-tag/src/lib.rs +++ b/crates/tag/google-tag/src/lib.rs @@ -1746,6 +1746,39 @@ mod tests { ); } + #[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!({ From 77d29690f63c19942f9e63a126ef5c596a3df2b3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:20:44 +0100 Subject: [PATCH 13/17] Make the example's tag id placeholder fail validation and list the module The example settings gave analytics_tag_id the placeholder G-REPLACE-ME, which the tag id pattern accepts, unlike the Tag Manager example's GTM-REPLACE-ME, which is invalid on purpose. A table uncommented with the placeholder left in would have written a tag that is not the publisher's. The placeholder is now G-REPLACE_ME, which the pattern refuses, and the example says the tag and container placeholders are invalid on purpose. AGENTS.md lists tag.google-tag among the page integrations and among the crates whose MODULE constant a test holds to the crate's folder. A new test reads each of the two placeholders from the example file itself and checks that the module refuses it, naming the setting. --- AGENTS.md | 20 ++++++++++---------- crates/tag/google-tag/src/lib.rs | 28 ++++++++++++++++++++++++++++ trusted-server.example.toml | 5 +++-- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a2691b7afa..61c84b0305 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -393,15 +393,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. @@ -442,7 +442,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 `" - )); - } - scripts } + /// The loader tag, which asks for GPT on this module's own route so the + /// whole script chain stays first party. A serve middleware's head markup + /// goes after every fetch middleware's, so the tag follows the shim and + /// the bootstrap it relies on. + fn loader_tag() -> String { + format!("") + } + /// 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)> { @@ -638,8 +730,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 { @@ -1559,55 +1665,292 @@ 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 head_inserts_write_the_loader_tag_when_asked() { - let integration = GptIntegration::new(GptConfig { + 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() - }); - let inserts = integration.head_markup(); + })); - assert_eq!( - inserts.len(), - 3, - "should add one head insert for the loader" + 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!( - inserts[2], r#""#, + plan.head_inserts, + vec![LOADER_TAG.to_owned()], "should ask for the loader on this module's own route" ); } #[test] - fn head_inserts_write_the_loader_tag_last() { - let integration = GptIntegration::new(GptConfig { - slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()), - write_loader: true, - ..test_config() - }); - let inserts = integration.head_markup(); + 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!({})); - assert_eq!( - inserts.len(), - 4, - "should add the loader after every other insert" + // 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!( - inserts[3].starts_with("