From 7b08d9439c460bfc46bfde72bc262682fa770ef3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 10:03:03 +0100 Subject: [PATCH 1/8] A consent prompt runs in the phase the publisher chooses `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` declare both phases, so an entry of either phase may name them. What a prompt writes is the same for every reader, so the fetch phase stores it in the page every reader is served from, which is what a generated configuration places, and the serve phase writes it on each reader's copy for a publisher who wants the stored page without it. Selecting the InMobi module with no entry of either phase naming it is still refused. The stock list keeps each prompt's row in the fetch phase, which is the phase `ts audit generate` writes, and its test allows a middleware that runs in several phases to be listed in one of them, checking that what is listed for a phase is registered for it and that everything registered is listed somewhere. --- crates/cmp/didomi/src/lib.rs | 15 ++++++- crates/cmp/inmobi/src/lib.rs | 50 ++++++++++++++++++------ crates/cmp/sourcepoint/src/lib.rs | 12 +++++- crates/trusted-server-modules/src/lib.rs | 33 ++++++++++++---- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 907b67ed6d..39559178b8 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -596,7 +596,8 @@ impl DidomiIntegration { } /// Writes the browser module's settings into the head, on the pages a -/// `[[fetch]]` entry names [`MODULE`] for. +/// `[[fetch]]` or `[[serve]]` entry names [`MODULE`] for, in the phase the +/// publisher chooses. struct ClientConfig(Arc); impl Middleware for ClientConfig { @@ -605,7 +606,7 @@ impl Middleware for ClientConfig { } fn phases(&self) -> &[MiddlewarePhase] { - &[MiddlewarePhase::Fetch] + &MiddlewarePhase::ALL } fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { @@ -1372,6 +1373,16 @@ mod tests { assert!(validate_proxy_path("/leading-slash-ok").is_ok()); } + #[test] + fn the_page_change_may_run_in_either_phase() { + let integration = DidomiIntegration::new(Arc::new(config())); + assert_eq!( + ClientConfig(integration).phases(), + MiddlewarePhase::ALL, + "the publisher chooses the phase by the entry" + ); + } + #[test] fn head_markup_emits_proxy_path() { let custom_config = DidomiIntegrationConfig { diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index ae6373a1fb..d5b62d106e 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -3,7 +3,8 @@ //! //! The prompt is the first thing a visitor sees and no advertising may run //! before they answer it. Its tag is this module's middleware, written where -//! a `[[fetch]]` entry names [`MODULE`], before any vendor's tag. The +//! a `[[fetch]]` or `[[serve]]` entry names [`MODULE`], before any vendor's +//! tag, in the phase the publisher chooses. The //! middleware writes the IAB TCF stub and `InMobi`'s GPP stub ahead of //! the deferred Choice loader, so a call to `__tcfapi` or `__gpp` made before //! the prompt arrives is answered or queued rather than thrown, and each stub @@ -37,8 +38,8 @@ use trusted_server_core::settings::{IntegrationConfig, Settings}; /// The id the prompt's registration carries. const INMOBI_INTEGRATION_ID: &str = "inmobi"; -/// The name `[cmp]` selects the prompt by and a `[[fetch]]` entry names its -/// middleware by, from this crate's folder. Its settings are the table `[cmp.inmobi]`. +/// The name `[cmp]` selects the prompt by and an entry names its middleware +/// by, from this crate's folder. Its settings are the table `[cmp.inmobi]`. pub const MODULE: &str = "cmp.inmobi"; /// Hosts a Choice tag may be served from. @@ -156,13 +157,16 @@ fn validate_script_url(url: &str) -> Result<(), Report> { /// Refuse a document that selects the prompt but names it in no entry, where /// it would load and never be written. fn require_an_entry(settings: &Settings) -> Result<(), Report> { - if settings.fetch.names().contains(&MODULE) { + if [&settings.fetch, &settings.serve] + .iter() + .any(|entries| entries.names().contains(&MODULE)) + { return Ok(()); } Err(Report::new(TrustedServerError::Settings { message: format!( - "[cmp] selects `inmobi`, but no [[fetch]] entry names `{MODULE}`, so the \ - consent prompt would never be written. Add it to the entry for \ + "[cmp] selects `inmobi`, but no [[fetch]] or [[serve]] entry names `{MODULE}`, \ + so the consent prompt would never be written. Add it to the entry for \ \"text/html\", before any middleware that writes a vendor's tag" ), })) @@ -235,8 +239,9 @@ pub fn builder() -> IntegrationBuilder { /// prompt, so an entry must name this middleware before any that writes a /// vendor's tag. /// -/// The fetch phase, because the tag is the same for every reader and belongs -/// in the page every reader is served from. +/// Either phase, as the publisher chooses by the entry. The tag is the same +/// for every reader, so the fetch phase stores it in the page every reader +/// is served from, and the serve phase writes it on each reader's copy. #[derive(Debug)] pub struct Choice { tag: Arc, @@ -265,7 +270,7 @@ impl Middleware for Choice { } fn phases(&self) -> &[MiddlewarePhase] { - &[MiddlewarePhase::Fetch] + &MiddlewarePhase::ALL } fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { @@ -382,11 +387,30 @@ mod tests { } #[test] - fn the_prompt_runs_in_the_fetch_phase_alone() { + fn the_prompt_may_run_in_either_phase() { assert_eq!( Choice::new(REAL, CMP_ID).phases(), - [MiddlewarePhase::Fetch], - "the tag is the same for every reader, so it belongs in the stored page" + MiddlewarePhase::ALL, + "the publisher chooses the phase by the entry" + ); + } + + #[test] + fn a_prompt_named_in_a_serve_entry_alone_is_accepted() { + let settings = page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\n\n\ + [[serve]]\nmedia_type = \"text/html\"\nmiddleware = [\"cmp.inmobi\"]\n" + )); + assert!( + validate(&settings).expect("should read"), + "a serve entry places the prompt" + ); + let registration = register(&settings) + .expect("should parse") + .expect("should register"); + assert_eq!( + head_markup(MiddlewarePhase::Serve, registration.middleware).len(), + 1 ); } @@ -418,7 +442,7 @@ mod tests { let refused = validate(&settings).expect_err("should refuse a prompt no entry names"); assert!( - format!("{refused:?}").contains("no [[fetch]] entry names `cmp.inmobi`"), + format!("{refused:?}").contains("no [[fetch]] or [[serve]] entry names `cmp.inmobi`"), "{refused:?}" ); assert!(register(&settings).is_err()); diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index b13e9dc96d..7894cb9bc8 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -1046,7 +1046,7 @@ impl Middleware for PageChange { } fn phases(&self) -> &[MiddlewarePhase] { - &[MiddlewarePhase::Fetch] + &MiddlewarePhase::ALL } fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { @@ -1505,6 +1505,16 @@ mod tests { )); } + #[test] + fn the_page_change_may_run_in_either_phase() { + let integration = SourcepointIntegration::new(Arc::new(config())); + assert_eq!( + PageChange(integration).phases(), + MiddlewarePhase::ALL, + "the publisher chooses the phase by the entry" + ); + } + #[test] fn head_markup_emits_config_script_plus_trap_when_enabled() { let integration = SourcepointIntegration::new(Arc::new(config())); diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 57aac2679b..90b4536440 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -215,17 +215,34 @@ mod tests { .map(|integration| integration.id) .collect(); + // A middleware that runs in several phases is listed in the one a + // tool writes it in, so what is listed for a phase is registered + // for it, and everything registered is listed somewhere. + let listed_anywhere: Vec<&str> = listed + .iter() + .filter(|row| running.contains(&row.integration)) + .map(|row| row.name) + .collect(); for phase in MiddlewarePhase::ALL { - let expected: Vec<&str> = listed + let registered = registry.middleware_in(phase); + for row in listed .iter() .filter(|row| row.phase == phase && running.contains(&row.integration)) - .map(|row| row.name) - .collect(); - assert_eq!( - registry.middleware_in(phase), - expected, - "should list for {phase} what the modules {running:?} register for it" - ); + { + assert!( + registered.contains(&row.name), + "{} is listed for {phase} and the modules {running:?} do not register it \ + for that phase: {registered:?}", + row.name + ); + } + for name in ®istered { + assert!( + listed_anywhere.contains(name), + "{name} is registered for {phase} by the modules {running:?} and listed \ + in no phase" + ); + } } checked.extend(running); } From 9cb281d6c27e66b872005d0796e84a367e15645f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 10:03:04 +0100 Subject: [PATCH 2/8] The guides say a consent prompt's phase is the publisher's choice The InMobi guide places the prompt by an entry of either phase and says what each phase does with it, the configuration guide's middleware table says the same for every consent management platform, and the changelog records the change. --- CHANGELOG.md | 1 + docs/guide/configuration.md | 5 ++++- docs/guide/integrations/inmobi.md | 18 +++++++++++------- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f480d2a597..efe6a7114e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. - **Breaking:** `ts cache purge` asks the hosting platform to purge instead of calling the service. It takes `--service-id ` where it took `--service `, reads a Fastly API token from `FASTLY_API_TOKEN` where it read `TRUSTED_SERVER_ADMIN_PASSWORD`, and no longer takes `--username`. It runs `fastly service purge --key`, which needs Fastly CLI 14.0.0 or later, with the surrogate key it derives by the function the service tags its cached objects with, so `--all` and `--page ` purge exactly what they purged before. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 951ad93826..eb3bf6fc19 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1813,7 +1813,10 @@ The modules this repository ships supply the middleware below. A deployment that runs several of them names them all in one entry's list, because a page takes one entry, and the order written is the order they run in. The table is in the order the modules register, which is the order `ts audit` writes -the names in and the order `trusted-server.example.toml` shows them in. +the names in and the order `trusted-server.example.toml` shows them in. A +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 | | ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md index a8d01a7a6c..8d65c11fe4 100644 --- a/docs/guide/integrations/inmobi.md +++ b/docs/guide/integrations/inmobi.md @@ -12,7 +12,8 @@ InMobi Choice is a TCF consent prompt loaded from the publisher's own InMobi account. The module writes the prompt's tag into the head of each page a -`[[fetch]]` entry names it for, in three parts and in this order: +`[[fetch]]` or `[[serve]]` entry names it for, in three parts and in this +order: 1. The IAB TCF v2 stub, which creates the `__tcfapiLocator` frame, queues every `__tcfapi` call made before the prompt arrives, answers `ping` as a @@ -57,16 +58,19 @@ The table refuses a field it does not read. ## Placing the prompt -The prompt changes a page only where a `[[fetch]]` entry names `cmp.inmobi`, -in the fetch phase alone, because the tag is the same for every reader and -belongs in the page every reader is served from. Core writes each +The prompt changes a page only where a `[[fetch]]` or `[[serve]]` entry +names `cmp.inmobi`, in the phase the publisher chooses. The tag is the same +for every reader, so the fetch phase stores it in the page every reader is +served from, which is what `ts audit generate` writes, and the serve phase +writes it on each reader's copy instead. Core writes each middleware's head markup in the order the entry names them, and nothing advertising related may run before the visitor answers the prompt, so name `cmp.inmobi` before any middleware that writes a vendor's tag, as the example above does ahead of Google Tag Manager. -Selecting the module with no entry naming it is refused when the settings -load and by `ts config validate`, because the prompt would never be written. +Selecting the module with no entry of either phase naming it is refused +when the settings load and by `ts config validate`, because the prompt would +never be written. ## What is checked @@ -86,7 +90,7 @@ reader is asked before anything else runs. This module keeps to the following, and they are what every `[cmp]` module is held to. 1. The prompt is written before any vendor's tag, and the entry's order is - the head's order. + the head's order. Which phase it is written in is the publisher's choice. 2. The IAB interfaces a page may call before the prompt arrives, `__tcfapi` and `__gpp`, are stubbed ahead of the loader and queue or answer every call, and a stub steps aside where the page already has the interface. From 4ce40e21c42cba4df3b2523bc0c7d7532f5adbd7 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:18:44 +0100 Subject: [PATCH 3/8] The InMobi guide says where a prompt in the serve phase lands A serve middleware's head markup is written after every fetch middleware's, so a prompt placed in the serve phase comes after a vendor's tag a fetch entry writes. The guide now says to place the prompt in the fetch phase wherever a fetch middleware writes a vendor's tag. The Sourcepoint page change's doc names both phases, as Didomi's does. --- crates/cmp/sourcepoint/src/lib.rs | 4 ++-- docs/guide/integrations/inmobi.md | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index 353cfbca29..21916f7fe1 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -1032,8 +1032,8 @@ impl IntegrationProxy for SourcepointIntegration { } } -/// Sourcepoint's change to a page, on the pages a `[[fetch]]` entry names -/// [`MODULE`] for. It writes the browser module's settings into the head and, +/// Sourcepoint's change to a page, on the pages a `[[fetch]]` or `[[serve]]` +/// entry names [`MODULE`] for, in the phase the publisher chooses. It writes the browser module's settings into the head and, /// when `rewrite_sdk` is set, the trap on `window._sp_`, and points the /// CDN's addresses at the first-party path. struct PageChange(Arc); diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md index 78628e21e6..e0e45011a4 100644 --- a/docs/guide/integrations/inmobi.md +++ b/docs/guide/integrations/inmobi.md @@ -65,7 +65,10 @@ writes it on each reader's copy instead. Core writes each middleware's head markup in the order the entry names them, and nothing advertising related may run before the reader answers the prompt, so name `cmp.inmobi` before any middleware that writes a vendor's tag, as the example -above does ahead of Google Tag Manager. +above does ahead of Google Tag Manager. A serve middleware's head markup is +written after every fetch middleware's, so a prompt placed in `[[serve]]` +comes after a vendor's tag that a `[[fetch]]` entry writes. Place the prompt +in `[[fetch]]` wherever a fetch middleware writes a vendor's tag. Selecting the module with no `text/html` entry of either phase naming it is refused when the settings load and by `ts config validate`, because the From d09b2822536856b47a4abbb4da4885c55aa1f7cb Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 16:50:44 +0100 Subject: [PATCH 4/8] Format the merged InMobi placement check --- crates/cmp/inmobi/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 6f0e623691..449f5cc037 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -133,7 +133,8 @@ fn validate_script_url(url: &str) -> Result<(), Report> { fn require_an_entry(settings: &Settings) -> Result<(), Report> { let placed = [&settings.fetch, &settings.serve].iter().any(|entries| { entries.entries().iter().any(|entry| { - entry.media_type == HTML_MEDIA_TYPE && entry.middleware.iter().any(|name| name == MODULE) + entry.media_type == HTML_MEDIA_TYPE + && entry.middleware.iter().any(|name| name == MODULE) }) }); if placed { From ffea14ae5800f862d911c713cd43ee70a6442f95 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 08:28:20 +0100 Subject: [PATCH 5/8] A serve entry may not place the consent prompt after fetch middleware A serve middleware's head markup is written after every fetch middleware's, so a prompt named in [[serve]] followed any vendor's tag the [[fetch]] entry for the same page wrote into the head. The InMobi module now refuses, when the service starts and in ts config validate, a [[serve]] entry naming cmp.inmobi for a page whose [[fetch]] entry names another middleware. The module cannot tell which middleware write a vendor's tag, so it refuses any. Pages are compared by the entries each phase gives them, so entries under paths that never meet are accepted. --- CHANGELOG.md | 2 +- crates/cmp/inmobi/src/lib.rs | 272 +++++++++++++++++++++++++++++- docs/guide/configuration.md | 9 +- docs/guide/integrations/inmobi.md | 11 +- 4 files changed, 280 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ec7d3e72..73af480765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. +- A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. A `[[serve]]` entry naming `cmp.inmobi` for a page whose `[[fetch]]` entry names another middleware is refused, because the serve phase writes its head markup after the fetch phase's, so a vendor's tag would come ahead of the prompt. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. - **Breaking:** `ts cache purge` asks the hosting platform to purge instead of calling the service. It takes `--service-id ` where it took `--service `, reads a Fastly API token from `FASTLY_API_TOKEN` where it read `TRUSTED_SERVER_ADMIN_PASSWORD`, and no longer takes `--username`. It runs `fastly service purge --key`, which needs Fastly CLI 14.0.0 or later, with the surrogate key it derives by the function the service tags its cached objects with, so `--all` and `--page ` purge exactly what they purged before. diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 449f5cc037..68fa817160 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -149,20 +149,84 @@ fn require_an_entry(settings: &Settings) -> Result<(), Report Vec<&str> { + let mut paths = vec!["/"]; + for entry in settings + .fetch + .entries() + .iter() + .chain(settings.serve.entries()) + { + if entry.media_type != HTML_MEDIA_TYPE { + continue; + } + if let Some(path) = entry.path.as_deref() + && !paths.contains(&path) + { + paths.push(path); + } + } + paths +} + +/// Refuse a document that would write the prompt on a page after the fetch +/// phase's head markup. +/// +/// A serve middleware's head markup is written after every fetch +/// middleware's, so a prompt that a `[[serve]]` entry names follows whatever +/// the `[[fetch]]` entry for the same page writes into the head. Which of +/// those middleware write a vendor's tag is not known here, so any is +/// refused. +fn require_the_prompt_first(settings: &Settings) -> Result<(), Report> { + for path in page_paths(settings) { + let serve = settings.serve.for_response(HTML_MEDIA_TYPE, path); + if !serve.iter().any(|name| name == MODULE) { + continue; + } + let fetch = settings.fetch.for_response(HTML_MEDIA_TYPE, path); + let ahead: Vec<&str> = fetch + .iter() + .map(String::as_str) + .filter(|name| *name != MODULE) + .collect(); + if !ahead.is_empty() { + return Err(Report::new(TrustedServerError::Settings { + message: format!( + "[[serve]] names `{MODULE}` for a page at `{path}`, where [[fetch]] names \ + [{}]. A serve middleware's head markup is written after every fetch \ + middleware's, so a vendor's tag any of those write would come ahead of \ + the consent prompt. Name `{MODULE}` first in that [[fetch]] entry instead", + ahead.join(", ") + ), + })); + } + } + Ok(()) +} + /// Validates the `InMobi` Choice configuration and reports whether a section /// selects the module. /// /// # Errors /// /// Returns an error when the configuration cannot be parsed, fails -/// validation, carries a script URL that should not be served, or names the -/// prompt in no entry. +/// validation, carries a script URL that should not be served, names the +/// prompt in no entry, or would write it after the fetch phase's head +/// markup. pub(crate) fn validate(settings: &Settings) -> Result> { let Some(config) = settings.module_config::(MODULE)? else { return Ok(false); }; validate_script_url(&config.script_url)?; require_an_entry(settings)?; + require_the_prompt_first(settings)?; Ok(true) } @@ -171,7 +235,8 @@ pub(crate) fn validate(settings: &Settings) -> Result Result, Report> { @@ -180,6 +245,7 @@ pub fn register( }; validate_script_url(&config.script_url)?; require_an_entry(settings)?; + require_the_prompt_first(settings)?; Ok(Some( IntegrationRegistration::builder(INMOBI_INTEGRATION_ID) // No browser module. The two stubs and the Choice script are the @@ -260,9 +326,9 @@ fn escape_attribute(value: &str) -> String { mod tests { use super::*; use trusted_server_core::html_processor::test_support::{ - assert_page_is_recorded, page_settings, + assert_page_is_recorded, page_settings, processed_page, }; - use trusted_server_core::integrations::IntegrationDocumentState; + use trusted_server_core::integrations::{IntegrationDocumentState, IntegrationRegistry}; use trusted_server_core::middleware::test_support::context; use trusted_server_core::middleware::{MiddlewareChain, PhaseEntries, PhaseEntry}; @@ -328,6 +394,202 @@ mod tests { } } + /// The name an entry gives [`VendorTag`], and the module that supplies + /// it, selected by `[vendor] module = "tag"`. + const VENDOR_TAG: &str = "vendor.tag"; + + /// The module that supplies [`VendorTag`]. + fn vendor_builder() -> IntegrationBuilder { + IntegrationBuilder::new( + "vendor_tag", + env!("CARGO_PKG_NAME"), + |_| { + Ok(Some( + IntegrationRegistration::builder("vendor_tag") + .without_js() + .with_middleware(Arc::new(VendorTag)) + .build(), + )) + }, + |_| Ok(true), + ) + .with_module_name(VENDOR_TAG) + } + + /// An entry of `phase` for `text/html`, under `path` where one is given, + /// naming `middleware` in order. + fn entry(phase: MiddlewarePhase, path: Option<&str>, middleware: &[&str]) -> String { + let path = path.map_or_else(String::new, |path| format!("path = \"{path}\"\n")); + let names: Vec = middleware + .iter() + .map(|name| format!("\"{name}\"")) + .collect(); + format!( + "[[{phase}]]\nmedia_type = \"text/html\"\n{path}middleware = [{}]\n\n", + names.join(", ") + ) + } + + /// A document selecting the prompt and the vendor's tag, with `entries` + /// placing them, every `[[fetch]]` entry before any `[[serve]]` one. + fn placed(entries: &[String]) -> Settings { + page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\n\n\ + [vendor]\nmodule = \"tag\"\n\n{}", + entries.concat() + )) + } + + /// The page a reader receives from `settings`, run through both phases, + /// or why the service refuses to start. + fn served(settings: &Settings) -> Result { + let registry = IntegrationRegistry::with_registrations( + settings, + &[super::builder(), vendor_builder()], + ) + .map_err(|refused| refused.current_context().to_string())?; + Ok(processed_page( + settings, + ®istry, + "A page

Text

", + 8192, + )) + } + + /// The prompt's loader, the last of its three parts. + fn loader() -> String { + format!("") + } + + /// Wherever the service starts with the prompt and a vendor's tag placed, + /// a reader's page carries the whole prompt ahead of the tag, and the + /// placement that would write the tag first is refused. + #[test] + fn no_placement_the_service_starts_with_puts_a_vendor_tag_ahead_of_the_prompt() { + use MiddlewarePhase::{Fetch, Serve}; + // (case, entries, whether the service starts) + let cases = [ + ( + "both in [[fetch]], the prompt first", + vec![entry(Fetch, None, &[MODULE, VENDOR_TAG])], + true, + ), + ( + "both in [[serve]], the prompt first", + vec![entry(Serve, None, &[MODULE, VENDOR_TAG])], + true, + ), + ( + "the prompt in [[fetch]], the tag in [[serve]]", + vec![ + entry(Fetch, None, &[MODULE]), + entry(Serve, None, &[VENDOR_TAG]), + ], + true, + ), + ( + "the prompt in [[serve]], the tag in [[fetch]]", + vec![ + entry(Fetch, None, &[VENDOR_TAG]), + entry(Serve, None, &[MODULE]), + ], + false, + ), + ]; + for (case, entries, starts) in cases { + let settings = placed(&entries); + match served(&settings) { + Ok(page) => { + let position = |needle: &str| { + page.find(needle) + .unwrap_or_else(|| panic!("{case}: {needle} should be written: {page}")) + }; + assert!( + position(&loader()) < position("vendor.example"), + "{case}: the reader's page carries the vendor's tag ahead of the \ + prompt: {page}" + ); + assert!(starts, "{case}: the service should refuse to start: {page}"); + } + Err(refused) => { + assert!(!starts, "{case}: the service should start: {refused}"); + assert!( + refused.contains( + "[[serve]] names `cmp.inmobi` for a page at `/`, where [[fetch]] \ + names [vendor.tag]" + ), + "{case}: {refused}" + ); + let validated = validate(&settings) + .expect_err("`ts config validate` should refuse it too") + .current_context() + .to_string(); + assert_eq!(validated, refused, "{case}"); + } + } + } + } + + /// The prompt is refused after the fetch phase's head markup on the pages + /// both phases reach and on no others, whatever paths the entries cover. + #[test] + fn a_prompt_after_the_fetch_phase_is_refused_on_the_pages_both_phases_reach() { + use MiddlewarePhase::{Fetch, Serve}; + // (case, entries, whether it is refused) + let cases = [ + ( + "the tag under /news/, the prompt in [[serve]] everywhere", + vec![ + entry(Fetch, Some("/news/"), &[VENDOR_TAG]), + entry(Serve, None, &[MODULE]), + ], + true, + ), + ( + "the tag everywhere, the prompt in [[serve]] under /sport/", + vec![ + entry(Fetch, None, &[VENDOR_TAG]), + entry(Serve, Some("/sport/"), &[MODULE]), + ], + true, + ), + ( + "the tag under /news/, the prompt in [[serve]] under /news/sport/", + vec![ + entry(Fetch, Some("/news/"), &[VENDOR_TAG]), + entry(Serve, Some("/news/sport/"), &[MODULE]), + ], + true, + ), + ( + "the tag under /news/sport/, the prompt in [[serve]] under /news/", + vec![ + entry(Fetch, Some("/news/sport/"), &[VENDOR_TAG]), + entry(Serve, Some("/news/"), &[MODULE]), + ], + true, + ), + ( + "the tag under /news/, the prompt in [[serve]] under /sport/", + vec![ + entry(Fetch, Some("/news/"), &[VENDOR_TAG]), + entry(Serve, Some("/sport/"), &[MODULE]), + ], + false, + ), + ]; + for (case, entries, refused) in cases { + let settings = placed(&entries); + assert_eq!( + validate(&settings).is_err(), + refused, + "{case}: {:?}", + validate(&settings) + ); + assert_eq!(register(&settings).is_err(), refused, "{case}"); + } + } + #[test] fn module_constant_is_the_crate_folder() { assert_eq!( diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index fb718ad539..41ee1c9269 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1932,10 +1932,13 @@ See [Didomi](/guide/integrations/didomi) for the routed endpoint shapes. | `cmp_id` | Number | `10` | The IAB registered CMP identifier the GPP stub answers `ping` with while the prompt loads | The table refuses a field it does not read. Selecting the module with no -`[[fetch]]` entry naming `cmp.inmobi` is refused, because the +`[[fetch]]` or `[[serve]]` entry naming `cmp.inmobi` is refused, because the 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. +that writes a vendor's tag. A `[[serve]]` entry naming it for a page whose +`[[fetch]]` entry names another middleware is refused, because a serve +middleware's head markup is written after every fetch middleware's. See +[InMobi Choice](/guide/integrations/inmobi) for the tag's parts and what the +module keeps to. ### Google Tag Manager Integration diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md index e0e45011a4..f291950413 100644 --- a/docs/guide/integrations/inmobi.md +++ b/docs/guide/integrations/inmobi.md @@ -66,13 +66,14 @@ middleware's head markup in the order the entry names them, and nothing advertising related may run before the reader answers the prompt, so name `cmp.inmobi` before any middleware that writes a vendor's tag, as the example above does ahead of Google Tag Manager. A serve middleware's head markup is -written after every fetch middleware's, so a prompt placed in `[[serve]]` -comes after a vendor's tag that a `[[fetch]]` entry writes. Place the prompt -in `[[fetch]]` wherever a fetch middleware writes a vendor's tag. +written after every fetch middleware's, and the module cannot tell which +middleware write a vendor's tag, so a `[[serve]]` entry may name the prompt +only for pages whose `[[fetch]]` entry names no other middleware. Selecting the module with no `text/html` entry of either phase naming it is -refused when the settings load and by `ts config validate`, because the -prompt would never be written. +refused when the service starts and by `ts config validate`, because the +prompt would never be written. So is a `[[serve]]` entry naming it for a page +whose `[[fetch]]` entry names another middleware. The tag is written at the start of the head, ahead of the page's own ``, and is about 3.6 KB. A browser looks for `` From 5e621b1592964e50368137b59936c947c1c17383 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 08:31:58 +0100 Subject: [PATCH 6/8] The consent prompt is refused when both phases name it for one page A prompt named in both a [[fetch]] and a [[serve]] entry for the same page was written on it twice, loader included. The InMobi module now refuses that when the service starts and in ts config validate, and accepts each phase naming it for pages the other never reaches. The refusal of a serve prompt after fetch middleware now says that the serve phase writes after what the fetch phase writes ahead of the script bundle, since fetch markup written after the bundle lands after a serve prompt. --- CHANGELOG.md | 2 +- crates/cmp/inmobi/src/lib.rs | 105 ++++++++++++++++++++++++++---- docs/guide/configuration.md | 7 +- docs/guide/integrations/inmobi.md | 10 +-- 4 files changed, 102 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73af480765..7c5199645a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. A `[[serve]]` entry naming `cmp.inmobi` for a page whose `[[fetch]]` entry names another middleware is refused, because the serve phase writes its head markup after the fetch phase's, so a vendor's tag would come ahead of the prompt. +- A consent management platform's middleware runs in the phase the publisher chooses. `cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` may be named in a `[[fetch]]` or a `[[serve]]` entry, where each ran in the fetch phase alone. A generated configuration still places them in `[[fetch]]`. A `[[serve]]` entry naming `cmp.inmobi` for a page whose `[[fetch]]` entry names another middleware is refused, because the serve phase writes its head markup after what the fetch phase writes ahead of the script bundle, so a vendor's tag there would come ahead of the prompt. Naming `cmp.inmobi` for one page in both phases is refused, because the prompt would be written twice. - **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. - **Breaking:** `ts cache purge` asks the hosting platform to purge instead of calling the service. It takes `--service-id ` where it took `--service `, reads a Fastly API token from `FASTLY_API_TOKEN` where it read `TRUSTED_SERVER_ADMIN_PASSWORD`, and no longer takes `--username`. It runs `fastly service purge --key`, which needs Fastly CLI 14.0.0 or later, with the surrogate key it derives by the function the service tags its cached objects with, so `--all` and `--page ` purge exactly what they purged before. diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 68fa817160..1548be494d 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -177,14 +177,16 @@ fn page_paths(settings: &Settings) -> Vec<&str> { } /// Refuse a document that would write the prompt on a page after the fetch -/// phase's head markup. +/// phase's head markup, or in both phases. /// -/// A serve middleware's head markup is written after every fetch -/// middleware's, so a prompt that a `[[serve]]` entry names follows whatever -/// the `[[fetch]]` entry for the same page writes into the head. Which of -/// those middleware write a vendor's tag is not known here, so any is -/// refused. -fn require_the_prompt_first(settings: &Settings) -> Result<(), Report> { +/// A serve middleware's head markup is written straight before the script +/// bundle, after what the fetch phase wrote ahead of it, so a prompt that a +/// `[[serve]]` entry names follows whatever the `[[fetch]]` entry for the +/// same page writes there. Which of those middleware write a vendor's tag is +/// not known here, so any is refused. +fn require_the_prompt_once_and_first( + settings: &Settings, +) -> Result<(), Report> { for path in page_paths(settings) { let serve = settings.serve.for_response(HTML_MEDIA_TYPE, path); if !serve.iter().any(|name| name == MODULE) { @@ -200,13 +202,23 @@ fn require_the_prompt_first(settings: &Settings) -> Result<(), Report Result<(), Report Result> { let Some(config) = settings.module_config::(MODULE)? else { @@ -226,7 +238,7 @@ pub(crate) fn validate(settings: &Settings) -> Result Result Result, Report> { @@ -245,7 +257,7 @@ pub fn register( }; validate_script_url(&config.script_url)?; require_an_entry(settings)?; - require_the_prompt_first(settings)?; + require_the_prompt_once_and_first(settings)?; Ok(Some( IntegrationRegistration::builder(INMOBI_INTEGRATION_ID) // No browser module. The two stubs and the Choice script are the @@ -590,6 +602,71 @@ mod tests { } } + /// Named for one page in both phases, the prompt would be written on it + /// twice, loader and all, so the service refuses to start. Named in each + /// phase for pages that never meet, it is written once on each. + #[test] + fn a_prompt_named_for_one_page_in_both_phases_is_refused() { + use MiddlewarePhase::{Fetch, Serve}; + let both = placed(&[entry(Fetch, None, &[MODULE]), entry(Serve, None, &[MODULE])]); + match served(&both) { + Ok(page) => panic!( + "the service should refuse to start, and wrote the prompt's loader {} times: \ + {page}", + page.matches(&loader()).count() + ), + Err(refused) => assert!( + refused.contains( + "[[fetch]] and [[serve]] both name `cmp.inmobi` for a page at `/`, so the \ + consent prompt would be written on it twice" + ), + "{refused}" + ), + } + assert!( + validate(&both).is_err(), + "`ts config validate` should refuse it too" + ); + + // (case, entries, whether it is refused) + let cases = [ + ( + "[[fetch]] everywhere, [[serve]] under /sport/", + vec![ + entry(Fetch, None, &[MODULE]), + entry(Serve, Some("/sport/"), &[MODULE]), + ], + true, + ), + ( + "[[fetch]] under /news/, [[serve]] everywhere", + vec![ + entry(Fetch, Some("/news/"), &[MODULE]), + entry(Serve, None, &[MODULE]), + ], + true, + ), + ( + "[[fetch]] under /news/, [[serve]] under /sport/", + vec![ + entry(Fetch, Some("/news/"), &[MODULE]), + entry(Serve, Some("/sport/"), &[MODULE]), + ], + false, + ), + ]; + for (case, entries, refused) in cases { + let settings = placed(&entries); + assert_eq!( + validate(&settings).is_err(), + refused, + "{case}: {:?}", + validate(&settings) + ); + assert_eq!(register(&settings).is_err(), refused, "{case}"); + } + } + #[test] fn module_constant_is_the_crate_folder() { assert_eq!( diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 41ee1c9269..32e173f4c8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1936,9 +1936,10 @@ The table refuses a field it does not read. Selecting the module with no prompt would never be written, and the entry names it before any middleware that writes a vendor's tag. A `[[serve]]` entry naming it for a page whose `[[fetch]]` entry names another middleware is refused, because a serve -middleware's head markup is written after every fetch middleware's. See -[InMobi Choice](/guide/integrations/inmobi) for the tag's parts and what the -module keeps to. +middleware's head markup is written after what each fetch middleware writes +ahead of the script bundle, and so is naming it for one page in both phases, +which would write it twice. See [InMobi Choice](/guide/integrations/inmobi) +for the tag's parts and what the module keeps to. ### Google Tag Manager Integration diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md index f291950413..7eae098d58 100644 --- a/docs/guide/integrations/inmobi.md +++ b/docs/guide/integrations/inmobi.md @@ -66,14 +66,16 @@ middleware's head markup in the order the entry names them, and nothing advertising related may run before the reader answers the prompt, so name `cmp.inmobi` before any middleware that writes a vendor's tag, as the example above does ahead of Google Tag Manager. A serve middleware's head markup is -written after every fetch middleware's, and the module cannot tell which -middleware write a vendor's tag, so a `[[serve]]` entry may name the prompt -only for pages whose `[[fetch]]` entry names no other middleware. +written after what each fetch middleware writes ahead of the script bundle, +and the module cannot tell which middleware write a vendor's tag there, so a +`[[serve]]` entry may name the prompt only for pages whose `[[fetch]]` entry +names no other middleware. Selecting the module with no `text/html` entry of either phase naming it is refused when the service starts and by `ts config validate`, because the prompt would never be written. So is a `[[serve]]` entry naming it for a page -whose `[[fetch]]` entry names another middleware. +whose `[[fetch]]` entry names another middleware, and naming it for one page +in both phases, which would write it twice. The tag is written at the start of the head, ahead of the page's own ``, and is about 3.6 KB. A browser looks for `` From 07058b854f506c2bcc85f5c1e3b20fcec096597b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 08:33:21 +0100 Subject: [PATCH 7/8] A consent module placed in serve is run through the registry and a page No test ran cmp.inmobi, cmp.didomi or cmp.sourcepoint from a [[serve]] entry through the registry's check or a page. Each crate now builds the registry with its recorded page's settings moved from [[fetch]] to [[serve]] and checks that the reader receives the recorded page, which a page with no other middleware gets from either phase. --- crates/cmp/didomi/src/lib.rs | 29 +++++++++++++++++++++++++++++ crates/cmp/inmobi/src/lib.rs | 28 +++++++++++++++++++++++++++- crates/cmp/sourcepoint/src/lib.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 8e9d5a0087..1051b704fa 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -1505,4 +1505,33 @@ mod tests { ), ); } + + /// Named in `[[serve]]` in place of `[[fetch]]`, the page change passes + /// the registry's check and gives a reader the recorded page, because a + /// page with no other middleware gets it in the same place from either + /// phase. + #[test] + fn placed_in_serve_the_page_change_gives_a_reader_the_recorded_page() { + use trusted_server_core::html_processor::test_support::{ + page_settings, place_on_every_page, processed_page, + }; + let mut settings = page_settings(include_str!("fixtures/page-change.settings.toml")); + settings.fetch = trusted_server_core::middleware::PhaseEntries::default(); + place_on_every_page(&mut settings, MiddlewarePhase::Serve, &[MODULE]); + let registry = IntegrationRegistry::with_registrations(&settings, &[super::builder()]) + .expect("the registry should accept the page change in a serve entry"); + + let page = processed_page( + &settings, + ®istry, + include_str!("fixtures/page-change.input.html"), + 8192, + ); + + assert_eq!( + page, + include_str!("fixtures/page-change.recorded.html"), + "a serve entry should give the reader the page a fetch entry does" + ); + } } diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 1548be494d..f2a70e249a 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -338,7 +338,7 @@ fn escape_attribute(value: &str) -> String { mod tests { use super::*; use trusted_server_core::html_processor::test_support::{ - assert_page_is_recorded, page_settings, processed_page, + assert_page_is_recorded, page_settings, place_on_every_page, processed_page, }; use trusted_server_core::integrations::{IntegrationDocumentState, IntegrationRegistry}; use trusted_server_core::middleware::test_support::context; @@ -969,4 +969,30 @@ mod tests { ), ); } + + /// Named in `[[serve]]` in place of `[[fetch]]`, the prompt passes the + /// registry's check and gives a reader the recorded page, because a page + /// with no other middleware gets it in the same place from either phase. + #[test] + fn placed_in_serve_the_prompt_gives_a_reader_the_recorded_page() { + let mut settings = page_settings(include_str!("fixtures/page-change.settings.toml")); + settings.fetch = PhaseEntries::default(); + place_on_every_page(&mut settings, MiddlewarePhase::Serve, &[MODULE]); + let registry = IntegrationRegistry::with_registrations(&settings, &[super::builder()]) + .expect("the registry should accept the prompt in a serve entry"); + + let page = processed_page( + &settings, + ®istry, + include_str!("fixtures/page-change.input.html"), + 8192, + ); + + assert_eq!(page.matches(&loader()).count(), 1, "{page}"); + assert_eq!( + page, + include_str!("fixtures/page-change.recorded.html"), + "a serve entry should give the reader the page a fetch entry does" + ); + } } diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index 21916f7fe1..7e069d8bf8 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -2231,4 +2231,33 @@ mod tests { ), ); } + + /// Named in `[[serve]]` in place of `[[fetch]]`, the page change passes + /// the registry's check and gives a reader the recorded page, because a + /// page with no other middleware gets it in the same place from either + /// phase. + #[test] + fn placed_in_serve_the_page_change_gives_a_reader_the_recorded_page() { + use trusted_server_core::html_processor::test_support::{ + page_settings, place_on_every_page, processed_page, + }; + let mut settings = page_settings(include_str!("fixtures/page-change.settings.toml")); + settings.fetch = trusted_server_core::middleware::PhaseEntries::default(); + place_on_every_page(&mut settings, MiddlewarePhase::Serve, &[MODULE]); + let registry = IntegrationRegistry::with_registrations(&settings, &[super::builder()]) + .expect("the registry should accept the page change in a serve entry"); + + let page = processed_page( + &settings, + ®istry, + include_str!("fixtures/page-change.input.html"), + 8192, + ); + + assert_eq!( + page, + include_str!("fixtures/page-change.recorded.html"), + "a serve entry should give the reader the page a fetch entry does" + ); + } } From 9f79e870d63670ac8eda0098a5cdd0b93aa3ffb1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 20:43:16 +0100 Subject: [PATCH 8/8] The Sourcepoint phase test builds its page change with new The Sourcepoint page change became a struct that holds its template key and is built with `PageChange::new`, so the test that reads its phases builds it the same way, as the registration and the other tests do. --- crates/cmp/sourcepoint/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index 5045721f6c..4773da65f4 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -2386,7 +2386,7 @@ mod tests { fn the_page_change_may_run_in_either_phase() { let integration = SourcepointIntegration::new(Arc::new(config())); assert_eq!( - PageChange(integration).phases(), + PageChange::new(integration).phases(), MiddlewarePhase::ALL, "the publisher chooses the phase by the entry" );