From 97a31f2698ca81c04cc24f8d0a4d67f5d43fcb0e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 00:38:59 +0100 Subject: [PATCH 01/21] The InMobi Choice consent prompt is a module `[cmp] module = "inmobi"` selects it, with the account's Choice loader in `[cmp.inmobi] script_url` and the IAB CMP id in `cmp_id`, which is 10 unless written. Where a `[[fetch]]` entry names `cmp.inmobi`, the module's middleware writes three things into the head, in this order: the IAB TCF v2 stub, which queues every `__tcfapi` call made before the prompt arrives and answers `ping` as a stub; InMobi's GPP stub, which answers the generic `__gpp` commands at once with the CMP id and queues the rest; and the Choice loader, deferred, so a slow consent vendor never holds up the page and the loader never runs ahead of a stub the publisher's own head carries. Each stub defines nothing where the page already has its interface. The module serves no browser module, because the stubs and the Choice script are the whole client side. The prompt writes the reader's answer to the cookie the TCF permission signal reads, so the answer reaches the permission model on the next request the way every signal does. The script URL becomes a ` + + +An article on the origin + + + diff --git a/crates/cmp/inmobi/src/fixtures/page-change.recorded.html b/crates/cmp/inmobi/src/fixtures/page-change.recorded.html new file mode 100644 index 0000000000..d27ab443f9 --- /dev/null +++ b/crates/cmp/inmobi/src/fixtures/page-change.recorded.html @@ -0,0 +1,40 @@ + + + + +InMobi Choice + + + +An article on the origin + + + diff --git a/crates/cmp/inmobi/src/fixtures/page-change.settings.toml b/crates/cmp/inmobi/src/fixtures/page-change.settings.toml new file mode 100644 index 0000000000..a7cafb39dd --- /dev/null +++ b/crates/cmp/inmobi/src/fixtures/page-change.settings.toml @@ -0,0 +1,9 @@ +[cmp] +module = "inmobi" + +[cmp.inmobi] +script_url = "https://cmp.inmobi.com/choice/EXAMPLEacct00/www.publisher.example/choice.js?tag_version=V3" + +[[fetch]] +media_type = "text/html" +middleware = ["cmp.inmobi"] diff --git a/crates/cmp/inmobi/src/gpp_stub.js b/crates/cmp/inmobi/src/gpp_stub.js new file mode 100644 index 0000000000..3ea7537426 --- /dev/null +++ b/crates/cmp/inmobi/src/gpp_stub.js @@ -0,0 +1,29 @@ +(function(cmpId){ +if(typeof window.__gpp==="function")return; +var apis=["2:tcfeuv2","6:uspv1","7:usnatv1","8:usca","9:usvav1","10:uscov1","11:usutv1","12:usctv1"]; +function ping(){return{gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:apis,cmpId:cmpId,sectionList:[],applicableSections:[-1],gppString:"",parsedSections:{}};} +var stub=function(){var b=arguments;stub.queue=stub.queue||[];stub.events=stub.events||[]; +if(!b.length||(b.length==1&&b[0]=="queue"))return stub.queue; +if(b.length==1&&b[0]=="events")return stub.events; +var cmd=b[0],clb=b.length>1?b[1]:null,par=b.length>2?b[2]:null; +function answer(v,ok){if(typeof clb==="function")clb(v,ok);} +if(cmd==="ping"){answer(ping(),true);} +else if(cmd==="addEventListener"){if(!("lastId" in stub))stub.lastId=0;stub.lastId++;var lnr=stub.lastId; +stub.events.push({id:lnr,callback:clb,parameter:par}); +answer({eventName:"listenerRegistered",listenerId:lnr,data:true,pingData:ping()},true);} +else if(cmd==="removeEventListener"){var ok=false; +for(var i=0;i` on every page of a +//! site is an injection route, and a settings document reaches the service +//! from a config store rather than from a person reading it. So the URL must +//! be `https`, must be on a host this module recognizes and must carry no +//! user name or password, which every reader's browser would be sent. A typo +//! that would have pointed every reader's browser at somebody else's script +//! fails the deployment instead. + +use std::sync::Arc; + +use error_stack::Report; +use serde::Deserialize; +use validator::Validate; + +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; +use trusted_server_core::middleware::{ + Middleware, MiddlewareAction, MiddlewareContext, MiddlewarePhase, +}; +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]`. +pub const MODULE: &str = "cmp.inmobi"; + +/// Hosts a Choice tag may be served from. +/// +/// An allow list rather than a pattern, because the point is to refuse a URL +/// nobody intended and a pattern is what lets one through. +const ALLOWED_HOSTS: [&str; 2] = ["cmp.inmobi.com", "cdn.inmobi.com"]; + +/// The IAB TCF v2 stub, written ahead of the loader so a call to `__tcfapi` +/// made before the prompt arrives is queued rather than thrown. It creates the +/// `__tcfapiLocator` frame, queues every call, answers `ping` with +/// `cmpStatus` "stub" and `apiVersion` "2.2", relays calls posted from +/// frames, and defines nothing where a `__tcfapi` already exists. +/// `tests/tcf_stub.test.mjs` runs it in a stand-in for the browser, with +/// Node's test runner. +const TCF_STUB_SCRIPT: &str = include_str!("tcf_stub.js"); + +/// `InMobi`'s GPP stub, written after the TCF stub and ahead of the loader, as +/// `InMobi`'s own tag writes it, so a call to `__gpp` made before the prompt +/// arrives is answered or queued rather than thrown. It answers `ping` and the +/// other generic commands at once, with the CMP id in the ping data, queues +/// every other call for the prompt, creates the `__gppLocator` frame, relays +/// calls posted from frames, and defines nothing where a `__gpp` already +/// exists. `tests/gpp_stub.test.mjs` runs it in a stand-in for the browser, +/// with Node's test runner. +const GPP_STUB_SCRIPT: &str = include_str!("gpp_stub.js"); + +/// The stub as the head carries it, without the file's line ending, which a +/// checkout may write as either form. +fn tcf_stub() -> String { + format!("", TCF_STUB_SCRIPT.trim_end()) +} + +/// The GPP stub as the head carries it, told the CMP id. The page carries the +/// same bytes whichever line ending a checkout gave the file. +fn gpp_stub(cmp_id: u16) -> String { + let script = GPP_STUB_SCRIPT.trim_end().replace("\r\n", "\n"); + format!("") +} + +/// Configuration for the `InMobi` Choice consent prompt. +#[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct InMobiChoiceConfig { + /// The account's Choice loader, which is per publisher and per site. + /// + /// Checked by [`validate_script_url`] rather than only by the type, + /// because this becomes a `", + tcf_stub(), + gpp_stub(cmp_id), + escape_attribute(script_url.trim()) + )), + } + } +} + +impl Middleware for Choice { + fn middleware_id(&self) -> &'static str { + MODULE + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Fetch] + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction { + head_inserts: vec![self.tag.to_string()], + ..MiddlewareAction::pass() + } + } +} + +/// Escapes a value for an HTML double quoted attribute. +/// +/// The URL is checked before it reaches here, so this is the second of two +/// checks, because a URL on the right host can still carry a quote in its +/// query that would end the attribute early. +fn escape_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::html_processor::test_support::{ + assert_page_is_recorded, page_settings, + }; + use trusted_server_core::integrations::IntegrationDocumentState; + use trusted_server_core::middleware::MiddlewareChain; + use trusted_server_core::middleware::test_support::context; + + const REAL: &str = "https://cmp.inmobi.com/choice/EXAMPLEacct00/www.publisher.example/choice.js?tag_version=V3"; + + /// `InMobi`'s registered CMP id. + const CMP_ID: u16 = 10; + + /// A document selecting the prompt, named in a fetch entry. + fn settings_with(url: &str) -> Settings { + page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = {}\n\n\ + [[fetch]]\nmedia_type = \"text/html\"\nmiddleware = [\"cmp.inmobi\"]\n", + serde_json::to_string(url).expect("a string") + )) + } + + /// The head markup `middleware` write in `phase`, in the order written. + fn head_markup(phase: MiddlewarePhase, middleware: Vec>) -> Vec { + let document_state = IntegrationDocumentState::default(); + MiddlewareChain::new(phase, middleware) + .plan(&context(phase, &document_state)) + .expect("the chain should plan") + .head_inserts + } + + /// The one piece of head markup the prompt writes in `phase`. + fn tag_in(phase: MiddlewarePhase, prompt: Choice) -> String { + let mut markup = head_markup(phase, vec![Arc::new(prompt)]); + assert_eq!( + markup.len(), + 1, + "{phase}: the prompt writes one piece: {markup:?}" + ); + markup.remove(0) + } + + /// A stand-in for a vendor middleware that writes its own tag into the + /// head, such as a tag manager. + #[derive(Debug)] + struct VendorTag; + + impl Middleware for VendorTag { + fn middleware_id(&self) -> &'static str { + "vendor.tag" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &MiddlewarePhase::ALL + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction { + head_inserts: vec![ + "".to_owned(), + ], + ..MiddlewareAction::pass() + } + } + } + + #[test] + fn module_constant_is_the_crate_folder() { + assert_eq!( + MODULE, + trusted_server_core::module_name!(), + "should be named by the folder this crate lives in" + ); + } + + #[test] + fn an_unselected_module_does_not_register() { + let settings = page_settings(""); + assert!( + register(&settings) + .expect("should read a document that selects nothing") + .is_none() + ); + assert!(!validate(&settings).expect("should read a document that selects nothing")); + } + + #[test] + fn the_prompt_runs_in_the_fetch_phase_alone() { + assert_eq!( + Choice::new(REAL, CMP_ID).phases(), + [MiddlewarePhase::Fetch], + "the tag is the same for every reader, so it belongs in the stored page" + ); + } + + #[test] + fn the_prompt_is_offered_as_the_modules_middleware() { + let registration = register(&settings_with(REAL)) + .expect("should parse") + .expect("should register"); + + assert!( + registration + .middleware + .iter() + .any(|middleware| middleware.middleware_id() == MODULE), + "the prompt should be offered for an entry to write" + ); + assert!( + registration.js_disabled, + "the stubs and the Choice script are the whole client side" + ); + } + + /// Selected but named in no entry, the prompt would never be written. + #[test] + fn a_document_naming_the_prompt_in_no_entry_is_refused() { + let settings = page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\n" + )); + + let refused = validate(&settings).expect_err("should refuse a prompt no entry names"); + assert!( + format!("{refused:?}").contains("no [[fetch]] entry names `cmp.inmobi`"), + "{refused:?}" + ); + assert!(register(&settings).is_err()); + } + + /// A script URL that should never reach a reader's browser is refused at + /// load and at registration, with a message naming the section and the + /// reason. + #[test] + fn a_script_url_that_should_never_be_served_is_refused_for_its_reason() { + // (case, script URL, the reason the refusal must give) + let cases = [ + ("an empty URL", " ", "is empty"), + ( + "a value that is not a URL", + "cmp.inmobi.com/choice.js", + "is not a URL", + ), + ( + "plain http, which whoever carries the page can replace", + "http://cmp.inmobi.com/choice/a/b/choice.js", + "is \"http\", and a consent prompt loaded over anything but https", + ), + ( + "a host that is not InMobi's", + "https://evil.example.com/choice.js", + "points at \"evil.example.com\", which is not an InMobi Choice host", + ), + ( + "a host that merely contains an allowed name", + "https://cmp.inmobi.com.evil.example/choice.js", + "points at \"cmp.inmobi.com.evil.example\", which is not an InMobi", + ), + ( + "an allowed name before an `@`, which makes it a user name", + "https://cmp.inmobi.com@evil.example/choice.js", + "points at \"evil.example\", which is not an InMobi", + ), + ( + "a user name and password on an allowed host", + "https://user:secret@cmp.inmobi.com/choice/a/b/choice.js", + "carries a user name or password", + ), + ( + "a user name alone on an allowed host", + "https://user@cmp.inmobi.com/choice/a/b/choice.js", + "carries a user name or password", + ), + ( + "a password alone on an allowed host", + "https://:secret@cmp.inmobi.com/choice/a/b/choice.js", + "carries a user name or password", + ), + ]; + for (case, url, reason) in cases { + let settings = settings_with(url); + for (call, refusal) in [ + ("validate", validate(&settings).err()), + ("register", register(&settings).err()), + ] { + let message = refusal + .unwrap_or_else(|| panic!("{case}: {call} should refuse {url:?}")) + .current_context() + .to_string(); + assert!( + message.contains("`script_url` in [cmp.inmobi]") && message.contains(reason), + "{case}: {call} should say {reason:?}, and said {message:?}" + ); + } + } + } + + #[test] + fn the_cmp_id_defaults_to_inmobis_registered_number() { + let config = settings_with(REAL) + .module_config::(MODULE) + .expect("should read the table") + .expect("should select the module"); + assert_eq!(config.cmp_id, CMP_ID); + } + + /// The CMP id in `[cmp.inmobi]` is the one the GPP stub is told. + #[test] + fn the_configured_cmp_id_reaches_the_gpp_stub() { + let settings = page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\ncmp_id = 42\n\n\ + [[fetch]]\nmedia_type = \"text/html\"\nmiddleware = [\"cmp.inmobi\"]\n" + )); + let registration = register(&settings) + .expect("should parse") + .expect("should register"); + + let markup = head_markup(MiddlewarePhase::Fetch, registration.middleware).join(""); + + assert!(markup.contains("})(42);"), "{markup}"); + assert!(!markup.contains("})(10);"), "{markup}"); + } + + #[test] + fn an_unknown_field_is_refused_rather_than_ignored() { + let settings = page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\ntypo = true\n" + )); + assert!( + settings + .module_config::(MODULE) + .is_err() + ); + } + + /// The tag is written once, deferred and carrying the account's URL. + #[test] + fn the_tag_is_deferred_and_carries_the_url() { + let tag = tag_in(MiddlewarePhase::Fetch, Choice::new(REAL, CMP_ID)); + + let loader = ""; + assert_eq!(tag.matches(loader).count(), 1, "{tag}"); + assert!(tag.ends_with(loader), "the loader comes last: {tag}"); + } + + /// The prompt's loader calls `__tcfapi` and `__gpp` as soon as it runs, + /// so each stub has to be ahead of the loader, once, the TCF stub first. + #[test] + fn the_stubs_go_ahead_of_the_loader_once_each_with_the_tcf_stub_first() { + let tag = tag_in(MiddlewarePhase::Fetch, Choice::new(REAL, 42)); + + let tcf = tcf_stub(); + let gpp = gpp_stub(42); + assert!(gpp.ends_with("})(42);"), "{gpp}"); + assert_eq!(tag.matches(&tcf).count(), 1, "{tag}"); + assert_eq!(tag.matches(&gpp).count(), 1, "{tag}"); + let position = |needle: &str| { + tag.find(needle) + .unwrap_or_else(|| panic!("{needle} belongs in the tag: {tag}")) + }; + assert!( + position(&tcf) < position(&gpp) && position(&gpp) < position("cmp.inmobi.com"), + "the order is TCF stub, GPP stub, loader: {tag}" + ); + } + + #[test] + fn ampersands_and_quotes_in_the_url_are_escaped() { + let tag = tag_in( + MiddlewarePhase::Fetch, + Choice::new( + "https://cmp.inmobi.com/choice/abc/site/choice.js?a=1&b=\"2\"", + CMP_ID, + ), + ); + + assert!(tag.contains("a=1&b="2""), "{tag}"); + } + + /// The entry's order is the head's order, which is why the prompt must be + /// named before any vendor's tag. + #[test] + fn a_vendor_tag_named_after_the_prompt_is_written_after_it() { + let prompt_first = head_markup( + MiddlewarePhase::Fetch, + vec![Arc::new(Choice::new(REAL, CMP_ID)), Arc::new(VendorTag)], + ) + .join(""); + let vendor_first = head_markup( + MiddlewarePhase::Fetch, + vec![Arc::new(VendorTag), Arc::new(Choice::new(REAL, CMP_ID))], + ) + .join(""); + + let position = |page: &str, needle: &str| { + page.find(needle) + .unwrap_or_else(|| panic!("{needle} should be written: {page}")) + }; + assert!( + position(&prompt_first, "cmp.inmobi.com") < position(&prompt_first, "vendor.example"), + "named first, the prompt should load first: {prompt_first}" + ); + assert!( + position(&vendor_first, "vendor.example") < position(&vendor_first, "cmp.inmobi.com"), + "named second, the prompt loads after the vendor's tag, so the order \ + in the entry is what keeps it first: {vendor_first}" + ); + } + + /// The page a reader receives with this module running, kept as a file + /// so that changing how the page change is made can be shown to leave + /// the page as it was. + #[test] + fn the_page_a_reader_receives_is_the_recorded_one() { + assert_page_is_recorded( + include_str!("fixtures/page-change.settings.toml"), + &[super::builder()], + include_str!("fixtures/page-change.input.html"), + include_str!("fixtures/page-change.recorded.html"), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/fixtures/page-change.recorded.html" + ), + ); + } +} diff --git a/crates/cmp/inmobi/src/tcf_stub.js b/crates/cmp/inmobi/src/tcf_stub.js new file mode 100644 index 0000000000..49a0451bef --- /dev/null +++ b/crates/cmp/inmobi/src/tcf_stub.js @@ -0,0 +1 @@ +(function(){if(typeof window.__tcfapi==="function")return;var n="__tcfapiLocator",q=[],gdprApplies;(function add(){if(window.frames[n])return;if(document.body){var f=document.createElement("iframe");f.style.display="none";f.name=n;document.body.appendChild(f);}else{setTimeout(add,5);}})();window.__tcfapi=function(){var a=Array.prototype.slice.call(arguments);if(!a.length)return q;if(a[0]==="setGdprApplies"){if(a.length>3&&parseInt(a[1],10)===2&&typeof a[3]==="boolean"){gdprApplies=a[3];if(typeof a[2]==="function")a[2]("set",true);}}else if(a[0]==="ping"){if(typeof a[2]==="function")a[2]({gdprApplies:gdprApplies,cmpLoaded:false,cmpStatus:"stub",apiVersion:"2.2"});}else{q.push(a);}};window.addEventListener("message",function(e){var s=typeof e.data==="string",d={};try{d=s?JSON.parse(e.data):e.data;}catch(x){}var c=d&&d.__tcfapiCall;if(c){window.__tcfapi(c.command,c.version,function(r,ok){var m={__tcfapiReturn:{returnValue:r,success:ok,callId:c.callId}};if(e.source)e.source.postMessage(s?JSON.stringify(m):m,"*");},c.parameter);}},false);})(); diff --git a/crates/cmp/inmobi/tests/gpp_stub.test.mjs b/crates/cmp/inmobi/tests/gpp_stub.test.mjs new file mode 100644 index 0000000000..e9cdb7104e --- /dev/null +++ b/crates/cmp/inmobi/tests/gpp_stub.test.mjs @@ -0,0 +1,224 @@ +// Runs InMobi's GPP stub the InMobi Choice module writes into a page, in a +// small stand-in for the browser, and checks what a page and its frames see. +// Run with `node --test crates/cmp/inmobi/tests/gpp_stub.test.mjs`. + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const STUB = readFileSync(new URL('../src/gpp_stub.js', import.meta.url), 'utf8'); + +// The sections InMobi's own tag says its prompt supports. +const SUPPORTED_APIS = [ + '2:tcfeuv2', + '6:uspv1', + '7:usnatv1', + '8:usca', + '9:usvav1', + '10:uscov1', + '11:usutv1', + '12:usctv1', +]; + +// What ping answers while the stub is in place, for a CMP id. +function stubPing(cmpId) { + return { + gppVersion: '1.1', + cmpStatus: 'stub', + cmpDisplayStatus: 'hidden', + signalStatus: 'not ready', + supportedAPIs: SUPPORTED_APIS, + cmpId, + sectionList: [], + applicableSections: [-1], + gppString: '', + parsedSections: {}, + }; +} + +// A window and document holding only what the stub touches, with the stub +// told `cmpId` as the middleware tells it. `body` is false for a page whose +// body has not been parsed yet. +function browser({ cmpId = 10, body = true, gpp } = {}) { + const listeners = []; + const appended = []; + const timers = []; + const window = { + frames: {}, + addEventListener(type, listener) { + listeners.push({ type, listener }); + }, + }; + if (gpp) { + window.__gpp = gpp; + } + const document = { + body: body + ? { + appendChild(frame) { + appended.push(frame); + window.frames[frame.name] = frame; + }, + } + : null, + createElement(tag) { + return { tag, style: {} }; + }, + }; + const setTimeout = (callback, delay, ...args) => timers.push({ callback, delay, args }); + new Function('window', 'document', 'setTimeout', `${STUB.trimEnd()}(${cmpId});`)( + window, + document, + setTimeout + ); + return { window, document, listeners, appended, timers }; +} + +// Posts `data` to the window from a frame, returning what the frame was sent. +function post(page, data) { + const replies = []; + const source = { postMessage: (message, origin) => replies.push({ message, origin }) }; + for (const { type, listener } of page.listeners) { + if (type === 'message') { + listener({ data, source }); + } + } + return replies; +} + +test('ping answers at once with the CMP id the stub is told', () => { + for (const cmpId of [10, 42]) { + const page = browser({ cmpId }); + const answers = []; + + page.window.__gpp('ping', (data, success) => answers.push({ data, success })); + + assert.deepEqual(answers, [{ data: stubPing(cmpId), success: true }]); + assert.deepEqual(page.window.__gpp('queue'), [], 'ping is answered, not queued'); + } +}); + +test('other calls are queued and handed over', () => { + const page = browser(); + const callback = () => assert.fail('a queued call is answered by the prompt'); + + page.window.__gpp('getGPPData', callback); + page.window.__gpp('signalStatus', callback, 'parameter', '1.1'); + + const queued = [ + ['getGPPData', callback], + ['signalStatus', callback, 'parameter', '1.1'], + ]; + assert.deepEqual(page.window.__gpp('queue'), queued); + assert.deepEqual(page.window.__gpp(), queued); + assert.equal(page.window.__gpp.queue, page.window.__gpp('queue'), 'kept on the stub'); +}); + +test('a listener is registered and removed at once, with the ping data', () => { + const page = browser({ cmpId: 42 }); + const answers = []; + const answer = (data, success) => answers.push({ data, success }); + + page.window.__gpp('addEventListener', answer); + assert.deepEqual(answers, [ + { + data: { eventName: 'listenerRegistered', listenerId: 1, data: true, pingData: stubPing(42) }, + success: true, + }, + ]); + assert.deepEqual(page.window.__gpp('events'), [{ id: 1, callback: answer, parameter: null }]); + + page.window.__gpp('removeEventListener', answer, 1); + page.window.__gpp('removeEventListener', answer, 7); + assert.deepEqual(answers.slice(1), [ + { + data: { eventName: 'listenerRemoved', listenerId: 1, data: true, pingData: stubPing(42) }, + success: true, + }, + { + data: { eventName: 'listenerRemoved', listenerId: 7, data: false, pingData: stubPing(42) }, + success: true, + }, + ]); + assert.deepEqual(page.window.__gpp('events'), []); + assert.deepEqual(page.window.__gpp('queue'), [], 'listeners are not queued'); +}); + +test('hasSection, getSection and getField answer at once', () => { + const page = browser(); + const answers = []; + + for (const command of ['hasSection', 'getSection', 'getField']) { + page.window.__gpp( + command, + (data, success) => answers.push([command, data, success]), + 'tcfeuv2' + ); + } + + assert.deepEqual(answers, [ + ['hasSection', false, true], + ['getSection', null, true], + ['getField', null, true], + ]); + assert.deepEqual(page.window.__gpp('queue'), []); +}); + +test('a frame that posts a call is answered with the call id', () => { + const page = browser({ cmpId: 42 }); + + const objectReplies = post(page, { __gppCall: { command: 'ping', callId: 'object-call' } }); + assert.deepEqual(objectReplies, [ + { + message: { + __gppReturn: { returnValue: stubPing(42), success: true, callId: 'object-call' }, + }, + origin: '*', + }, + ]); + + // A frame that posts a string is answered with a string. + const stringReplies = post( + page, + JSON.stringify({ __gppCall: { command: 'ping', callId: 'string-call' } }) + ); + assert.equal(stringReplies.length, 1); + assert.equal(typeof stringReplies[0].message, 'string'); + const reply = JSON.parse(stringReplies[0].message).__gppReturn; + assert.equal(reply.callId, 'string-call'); + assert.equal(reply.returnValue.cmpId, 42); + + // Anything else posted to the page is left alone. + assert.deepEqual(post(page, 'not json'), []); + assert.deepEqual(post(page, { other: true }), []); + assert.deepEqual(post(page, { __gppCall: 'not a call' }), []); +}); + +test('creates the hidden __gppLocator frame that frames find the stub by', () => { + const page = browser(); + + assert.equal(page.appended.length, 1); + assert.equal(page.appended[0].tag, 'iframe'); + assert.equal(page.appended[0].name, '__gppLocator'); + assert.equal(page.appended[0].style.cssText, 'display:none'); + assert.equal(page.timers.length, 0); +}); + +test('before the body exists the locator frame waits for it', () => { + const page = browser({ body: false }); + + assert.equal(page.appended.length, 0); + assert.equal(page.timers.length, 1); + assert.equal(page.timers[0].delay, 10); + assert.deepEqual(page.timers[0].args, ['__gppLocator']); +}); + +test('a page that already has a __gpp keeps it', () => { + const existing = () => 'own'; + const page = browser({ gpp: existing }); + + assert.equal(page.window.__gpp, existing); + assert.equal(page.window.__gpp_stub, undefined, 'nothing is defined'); + assert.equal(page.listeners.length, 0, 'no relay is added'); + assert.equal(page.appended.length, 0, 'no locator frame is added'); +}); diff --git a/crates/cmp/inmobi/tests/tcf_stub.test.mjs b/crates/cmp/inmobi/tests/tcf_stub.test.mjs new file mode 100644 index 0000000000..80823bbe5d --- /dev/null +++ b/crates/cmp/inmobi/tests/tcf_stub.test.mjs @@ -0,0 +1,172 @@ +// Runs the IAB TCF v2 stub the InMobi Choice module writes into a page, in a +// small stand-in for the browser, and checks what a page and its frames see. +// Run with `node --test crates/cmp/inmobi/tests/tcf_stub.test.mjs`. + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const STUB = readFileSync(new URL('../src/tcf_stub.js', import.meta.url), 'utf8'); + +// A window and document holding only what the stub touches. `body` is false +// for a page whose body has not been parsed yet. +function browser({ body = true, tcfapi } = {}) { + const listeners = []; + const appended = []; + const timers = []; + const window = { + frames: {}, + addEventListener(type, listener) { + listeners.push({ type, listener }); + }, + }; + if (tcfapi) { + window.__tcfapi = tcfapi; + } + const document = { + body: body + ? { + appendChild(frame) { + appended.push(frame); + window.frames[frame.name] = frame; + }, + } + : null, + createElement(tag) { + return { tag, style: {} }; + }, + }; + const setTimeout = (callback, delay) => timers.push({ callback, delay }); + new Function('window', 'document', 'setTimeout', STUB)(window, document, setTimeout); + return { window, document, listeners, appended, timers }; +} + +// Posts `data` to the window from a frame, returning what the frame was sent. +function post(page, data) { + const replies = []; + const source = { postMessage: (message, origin) => replies.push({ message, origin }) }; + for (const { type, listener } of page.listeners) { + if (type === 'message') { + listener({ data, source }); + } + } + return replies; +} + +test('calls made before the prompt arrives are queued and handed over', () => { + const page = browser(); + const callback = () => {}; + + page.window.__tcfapi('getTCData', 2, callback); + page.window.__tcfapi('addEventListener', 2, callback); + + assert.deepEqual(page.window.__tcfapi(), [ + ['getTCData', 2, callback], + ['addEventListener', 2, callback], + ]); +}); + +test('ping answers as a stub that has not loaded', () => { + const page = browser(); + let answer; + + page.window.__tcfapi('ping', 2, (value) => { + answer = value; + }); + + assert.deepEqual(answer, { + gdprApplies: undefined, + cmpLoaded: false, + cmpStatus: 'stub', + apiVersion: '2.2', + }); + assert.deepEqual(page.window.__tcfapi(), [], 'ping is answered, not queued'); +}); + +test('setGdprApplies sets what ping reports', () => { + const page = browser(); + const set = []; + + page.window.__tcfapi('setGdprApplies', 2, (...args) => set.push(args), true); + let answer; + page.window.__tcfapi('ping', 2, (value) => { + answer = value; + }); + + assert.deepEqual(set, [['set', true]]); + assert.equal(answer.gdprApplies, true); + + // Anything but version 2 and a boolean is ignored. + page.window.__tcfapi('setGdprApplies', 1, () => set.push('v1'), false); + page.window.__tcfapi('setGdprApplies', 2, () => set.push('text'), 'false'); + page.window.__tcfapi('ping', 2, (value) => { + answer = value; + }); + assert.deepEqual(set, [['set', true]]); + assert.equal(answer.gdprApplies, true); +}); + +test('a frame that posts a call is answered with the call id', () => { + const page = browser(); + + const objectReplies = post(page, { + __tcfapiCall: { command: 'ping', version: 2, callId: 'object-call' }, + }); + assert.deepEqual(objectReplies, [ + { + message: { + __tcfapiReturn: { + returnValue: { + gdprApplies: undefined, + cmpLoaded: false, + cmpStatus: 'stub', + apiVersion: '2.2', + }, + success: undefined, + callId: 'object-call', + }, + }, + origin: '*', + }, + ]); + + // A frame that posts a string is answered with a string. + const stringReplies = post( + page, + JSON.stringify({ __tcfapiCall: { command: 'ping', version: 2, callId: 'string-call' } }) + ); + assert.equal(stringReplies.length, 1); + assert.equal(typeof stringReplies[0].message, 'string'); + assert.equal(JSON.parse(stringReplies[0].message).__tcfapiReturn.callId, 'string-call'); + + // Anything else posted to the page is left alone. + assert.deepEqual(post(page, 'not json'), []); + assert.deepEqual(post(page, { other: true }), []); +}); + +test('creates the hidden __tcfapiLocator frame that frames find the stub by', () => { + const page = browser(); + + assert.equal(page.appended.length, 1); + assert.equal(page.appended[0].tag, 'iframe'); + assert.equal(page.appended[0].name, '__tcfapiLocator'); + assert.equal(page.appended[0].style.display, 'none'); + assert.equal(page.timers.length, 0); +}); + +test('before the body exists the locator frame waits for it', () => { + const page = browser({ body: false }); + + assert.equal(page.appended.length, 0); + assert.equal(page.timers.length, 1); + assert.equal(page.timers[0].delay, 5); +}); + +test('a page that already has a __tcfapi keeps it', () => { + const existing = () => 'own'; + const page = browser({ tcfapi: existing }); + + assert.equal(page.window.__tcfapi, existing); + assert.equal(page.listeners.length, 0, 'no relay is added'); + assert.equal(page.appended.length, 0, 'no locator frame is added'); +}); diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml index 161d26974f..488c14fbde 100644 --- a/crates/trusted-server-modules/Cargo.toml +++ b/crates/trusted-server-modules/Cargo.toml @@ -23,6 +23,7 @@ trusted-server-auction-protocol-openrtb = { workspace = true } trusted-server-audience-permutive = { workspace = true } trusted-server-bot-protection-datadome = { workspace = true } trusted-server-cmp-didomi = { workspace = true } +trusted-server-cmp-inmobi = { workspace = true } trusted-server-cmp-osano = { workspace = true } trusted-server-cmp-sourcepoint = { workspace = true } trusted-server-core = { workspace = true } diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index c55f0ba443..04eabf82b2 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -33,6 +33,7 @@ pub fn builders() -> Vec { trusted_server_audience_permutive::builder(), trusted_server_identity_lockr::builder(), trusted_server_cmp_didomi::builder(), + trusted_server_cmp_inmobi::builder(), trusted_server_cmp_sourcepoint::builder(), trusted_server_cmp_osano::builder(), trusted_server_tag_google_tag_manager::builder(), @@ -99,6 +100,7 @@ pub fn middleware() -> Vec { use trusted_server_audience_permutive as permutive; use trusted_server_bot_protection_datadome as datadome; use trusted_server_cmp_didomi as didomi; + use trusted_server_cmp_inmobi as inmobi; use trusted_server_cmp_sourcepoint as sourcepoint; use trusted_server_core::integrations::js_asset_proxy; use trusted_server_framework_nextjs as nextjs; @@ -120,6 +122,7 @@ pub fn middleware() -> Vec { row(permutive::builder().id(), Fetch, permutive::MODULE), row(lockr::builder().id(), Fetch, lockr::MODULE), row(didomi::builder().id(), Fetch, didomi::MODULE), + row(inmobi::builder().id(), Fetch, inmobi::MODULE), row(sourcepoint::builder().id(), Fetch, sourcepoint::MODULE), row( google_tag_manager::builder().id(), @@ -171,6 +174,7 @@ mod tests { include_str!("../../audience/permutive/src/fixtures/page-change.settings.toml"), include_str!("../../identity/lockr/src/fixtures/page-change.settings.toml"), include_str!("../../cmp/didomi/src/fixtures/page-change.settings.toml"), + include_str!("../../cmp/inmobi/src/fixtures/page-change.settings.toml"), include_str!("../../cmp/sourcepoint/src/fixtures/page-change.settings.toml"), include_str!("../../tag/google-tag-manager/src/fixtures/page-change.settings.toml"), include_str!("../../bot-protection/datadome/src/fixtures/page-change.settings.toml"), @@ -368,6 +372,7 @@ mod tests { "audience.permutive", "identity.lockr", "cmp.didomi", + "cmp.inmobi", "cmp.sourcepoint", "cmp.osano", "tag.google-tag-manager", diff --git a/trusted-server.example.toml b/trusted-server.example.toml index dc419b14da..64a66daca6 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -329,6 +329,7 @@ pull_sync_concurrency = 3 # "audience.permutive", # "identity.lockr", # "cmp.didomi", +# "cmp.inmobi", # "cmp.sourcepoint", # "tag.google-tag-manager", # "bot-protection.datadome", @@ -769,6 +770,14 @@ auction_timeout_ms = 500 # Osano consent management. It has nothing to set, so [cmp] module = "osano" # is the whole configuration and it needs no table of its own. +# InMobi Choice consent prompt. `script_url` is required, https on cmp.inmobi.com +# or cdn.inmobi.com with no user name or password. Name `cmp.inmobi` in the +# [[fetch]] entry before any middleware that writes a vendor's tag. Selected +# with [cmp] module = "inmobi". +# [cmp.inmobi] +# script_url = "https://cmp.inmobi.com/choice///choice.js?tag_version=V3" +# cmp_id = 10 + # Permutive DMP. `organization_id` and `workspace_id` are required. # [audience] # module = "permutive" From 8fb826dbcef5af94a02397f066ee7b2a26b630d6 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 00:39:00 +0100 Subject: [PATCH 02/21] CI runs a module's page script tests with Node A module that writes a script into pages keeps that script's tests beside it, as `tests/*.test.mjs`, run with Node's test runner and no packages. The vitest job runs every such file after the browser bundle's tests, from the repository root, so a stub that changes is tested the way a page runs it. The InMobi Choice module's two stubs are the first. --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6fa88f1b3..cdaea964c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -351,3 +351,9 @@ jobs: - name: Run unit tests run: npm test -- --run + + # A module that writes a script into pages tests it in its own folder, + # with Node's test runner and no packages. + - name: Run module page script tests + working-directory: . + run: node --test "crates/*/*/tests/*.test.mjs" From 4e1b379a9953c5da22db47e5089bf3aad9621fa8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 00:39:01 +0100 Subject: [PATCH 03/21] The guides describe [cmp.inmobi] A guide page for the module, with its settings, where to place it, what is checked, and what a consent prompt module keeps to, in the sidebar beside the other consent management platforms. The configuration guide's section table, its list of the modules this repository ships, its placing table and its integration sections carry the module, the integrations overview lists it, the crate list in AGENTS.md names it, and the changelog records it. --- AGENTS.md | 2 +- CHANGELOG.md | 1 + docs/.vitepress/config.mts | 1 + docs/guide/configuration.md | 19 ++++- docs/guide/integrations-overview.md | 1 + docs/guide/integrations/inmobi.md | 108 ++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 docs/guide/integrations/inmobi.md diff --git a/AGENTS.md b/AGENTS.md index 29bd3b1de3..58df514094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ crates/ auction/ # auction module crates (aps, prebid, prebid-server) audience/ # audience module crates (permutive) bot-protection/ # bot protection module crates (datadome) - cmp/ # consent tool module crates (didomi, osano, sourcepoint) + cmp/ # consent tool module crates (didomi, inmobi, osano, sourcepoint) framework/ # framework module crates (nextjs) identity/ # identity module crates (lockr) tag/ # tag manager module crates (google-tag-manager) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e8abb511..4730e874ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, and a selected prompt that no entry places is refused. 51Degrees maintain the crate until InMobi adopt it. - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module's answer is held in the key-value store until its refresh is due, a module that cannot answer has its last answer served, and with none held the answer is `503` and never an empty file. Without the section `/robots.txt` reaches the publisher's origin as before. - `ts keys rotate` and `ts keys deactivate` rotate and retire the request signing keys from an operator's machine. They write the two stores the service links as `jwks_store` and `signing_keys` through the `fastly` CLI, with the token from `FASTLY_API_TOKEN`. A private key reaches `fastly` on its standard input and never in its arguments, and a key id no platform can store is refused before anything is written. A command that cannot read the config store writes nothing, and a delete that failed part way can be run again. - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e4d327628b..561f623f3a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -168,6 +168,7 @@ export default withMermaid( text: 'GPT Runtime Diagnostics', link: '/guide/integrations/gpt-diagnostics', }, + { text: 'InMobi Choice', link: '/guide/integrations/inmobi' }, { text: 'Lockr', link: '/guide/integrations/lockr' }, { text: 'Next.js', link: '/guide/integrations/nextjs' }, { text: 'Osano', link: '/guide/integrations/osano' }, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 76efd5302a..65812e8c4b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1636,6 +1636,7 @@ validation knows is listed below. | `[tag.google-tag-manager]` | [Google Tag Manager](/guide/integrations/google_tag_manager) | | `[ad-tag.google]` | [GPT](/guide/integrations/gpt) | | `[ad-tag.google.diagnostics]` | [GPT diagnostics](/guide/integrations/gpt-diagnostics) | +| `[cmp.inmobi]` | [InMobi Choice](/guide/integrations/inmobi) | | `[proxy.js_asset_proxy]` | [JS Asset Proxy](#js-asset-proxy-integration) (no dedicated guide) | | `[identity.lockr]` | [lockr](/guide/integrations/lockr) | | `[framework.nextjs]` | [Next.js](/guide/integrations/nextjs) | @@ -1659,7 +1660,7 @@ module = "nextjs" ``` The modules this repository ships, by section, are `[cmp]` `didomi`, -`sourcepoint` and `osano`; `[tag]` `google-tag-manager`; `[ad-tag]` `google` +`inmobi`, `sourcepoint` and `osano`; `[tag]` `google-tag-manager`; `[ad-tag]` `google` and `google.diagnostics`; `[bot-protection]` `datadome`; `[identity]` `lockr`; `[audience]` `permutive`; `[framework]` `nextjs`; `[auction]` `prebid` and `testing.testlight`; and `[proxy]` `js_asset_proxy`. A name @@ -1748,6 +1749,7 @@ the names in and the order `trusted-server.example.toml` shows them in. | `audience.permutive` | `[[fetch]]` | Points a `src` or `href` that is the Permutive SDK's address at `/integrations/permutive/sdk`, when `rewrite_sdk` is set | | `identity.lockr` | `[[fetch]]` | Points a `src` or `href` that is the lockr SDK's address at `/integrations/lockr/sdk`, when `rewrite_sdk` is set | | `cmp.didomi` | `[[fetch]]` | Writes `window.__tsjs_didomi` into the head, which hands the browser module the path Didomi is served under | +| `cmp.inmobi` | `[[fetch]]` | Writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order | | `cmp.sourcepoint` | `[[fetch]]` | Writes `window.__tsjs_sourcepoint` into the head and, when `rewrite_sdk` is set, the trap on `window._sp_`, and points a `src` or `href` on Sourcepoint's CDN at `/integrations/sourcepoint/cdn` | | `tag.google-tag-manager` | `[[fetch]]` | Points Google Tag Manager and Google Analytics addresses at `/integrations/google_tag_manager`, in `src` and `href` attributes and in the text of inline scripts | | `bot-protection.datadome` | `[[fetch]]` | Points a `src` or `href` that is a DataDome script's address at `/integrations/datadome`, when `rewrite_sdk` is set | @@ -1826,6 +1828,21 @@ to new configurations. See [Didomi](/guide/integrations/didomi) for the routed endpoint shapes. +### InMobi Choice Integration + +**Section**: `[cmp.inmobi]` + +| Field | Type | Default | Contract | +| ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- | +| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | +| `cmp_id` | Number | `10` | The IAB registered CMP identifier 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 +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 Manager Integration **Section**: `[tag.google-tag-manager]` diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md index e546db809a..e221b9a801 100644 --- a/docs/guide/integrations-overview.md +++ b/docs/guide/integrations-overview.md @@ -35,6 +35,7 @@ their operational procedures. | [`google_tag_manager`](/guide/integrations/google_tag_manager) | production | `[tag] modules = ["google-tag-manager"]` | settings builder | bundled | | [`gpt`](/guide/integrations/gpt) | production | `[ad-tag] modules = ["google"]` | settings builder | bundled | | [`gpt_diagnostics`](/guide/integrations/gpt-diagnostics) | development | `[ad-tag] modules = ["google.diagnostics"]` | settings builder | standalone | +| [`inmobi`](/guide/integrations/inmobi) | production | `[cmp] module = "inmobi"` | settings builder | none | | `js_asset_proxy` (no dedicated guide) | development | `[proxy] modules = ["js_asset_proxy"]` | settings builder | none | | [`lockr`](/guide/integrations/lockr) | production | `[identity] module = "lockr"` | settings builder | bundled | | [`nextjs`](/guide/integrations/nextjs) | production | `[framework] module = "nextjs"` | settings builder | none | diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md new file mode 100644 index 0000000000..a8d01a7a6c --- /dev/null +++ b/docs/guide/integrations/inmobi.md @@ -0,0 +1,108 @@ +# InMobi Choice Integration + +**Category**: Consent Management Platform + +**Status**: Production + +**Type**: Consent prompt tag, with the IAB stubs ahead of it + +**Maintainers**: 51Degrees, until InMobi adopt the crate (`crates/cmp/inmobi`) + +## Overview + +InMobi Choice is a TCF consent prompt loaded from the publisher's own InMobi +account. The module writes the prompt's tag into the head of each page a +`[[fetch]]` entry names it for, in three parts and in this order: + +1. The IAB TCF v2 stub, which creates the `__tcfapiLocator` frame, queues + every `__tcfapi` call made before the prompt arrives, answers `ping` as a + stub, and relays calls posted from frames. +2. InMobi's GPP stub, which answers `ping` and the other generic `__gpp` + commands at once with the configured CMP id, queues every other call for + the prompt, creates the `__gppLocator` frame, and relays calls posted + from frames. +3. The account's Choice loader, deferred, so a slow consent vendor never + holds up the page a reader came for and the loader never runs ahead of a + stub the publisher's own head carries. + +Each stub defines nothing where a page already has its interface. Nothing +else of the page is changed, and the module serves no browser module of its +own, because the two stubs and the Choice script are the whole client side. + +The prompt writes the reader's answer to the `euconsent-v2` cookie, which +the TCF permission signal module reads on the next request, so the answer +reaches the permission model the way every signal does. + +## Configuration + +```toml +[cmp] +module = "inmobi" + +[cmp.inmobi] +script_url = "https://cmp.inmobi.com/choice///choice.js?tag_version=V3" +# cmp_id = 10 + +[[fetch]] +media_type = "text/html" +middleware = ["cmp.inmobi", "tag.google-tag-manager"] +``` + +| Field | Type | Default | Contract | +| ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- | +| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | +| `cmp_id` | Number | `10` | The IAB registered CMP identifier the GPP stub answers `ping` with while the prompt loads | + +The table refuses a field it does not read. + +## Placing the prompt + +The prompt changes a page only where a `[[fetch]]` entry names `cmp.inmobi`, +in the fetch phase alone, because the tag is the same for every reader and +belongs in the page every reader is served from. Core writes each +middleware's head markup in the order the entry names them, and nothing +advertising related may run before the 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. + +## What is checked + +`script_url` becomes a `", @@ -296,8 +259,8 @@ mod tests { assert_page_is_recorded, page_settings, }; use trusted_server_core::integrations::IntegrationDocumentState; - use trusted_server_core::middleware::MiddlewareChain; use trusted_server_core::middleware::test_support::context; + use trusted_server_core::middleware::{MiddlewareChain, PhaseEntries, PhaseEntry}; const REAL: &str = "https://cmp.inmobi.com/choice/EXAMPLEacct00/www.publisher.example/choice.js?tag_version=V3"; @@ -414,7 +377,32 @@ 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`"), + refused + .current_context() + .to_string() + .contains("no [[fetch]] entry for \"text/html\" names `cmp.inmobi`"), + "{refused:?}" + ); + assert!(register(&settings).is_err()); + } + + /// Only an entry for `text/html` writes the prompt into a page, so an + /// entry for another media type naming it does not place it. + #[test] + fn an_entry_for_another_media_type_does_not_place_the_prompt() { + let mut settings = settings_with(REAL); + settings.fetch = PhaseEntries::new(vec![PhaseEntry { + media_type: "application/javascript".to_owned(), + path: None, + middleware: vec![MODULE.to_owned()], + }]); + + let refused = validate(&settings).expect_err("should refuse a prompt no page entry names"); + assert!( + refused + .current_context() + .to_string() + .contains("no [[fetch]] entry for \"text/html\" names `cmp.inmobi`"), "{refused:?}" ); assert!(register(&settings).is_err()); @@ -436,7 +424,7 @@ mod tests { ( "plain http, which whoever carries the page can replace", "http://cmp.inmobi.com/choice/a/b/choice.js", - "is \"http\", and a consent prompt loaded over anything but https", + "is \"http\", not https", ), ( "a host that is not InMobi's", diff --git a/crates/cmp/inmobi/src/tcf_stub.js b/crates/cmp/inmobi/src/tcf_stub.js index 49a0451bef..259227abec 100644 --- a/crates/cmp/inmobi/src/tcf_stub.js +++ b/crates/cmp/inmobi/src/tcf_stub.js @@ -1 +1 @@ -(function(){if(typeof window.__tcfapi==="function")return;var n="__tcfapiLocator",q=[],gdprApplies;(function add(){if(window.frames[n])return;if(document.body){var f=document.createElement("iframe");f.style.display="none";f.name=n;document.body.appendChild(f);}else{setTimeout(add,5);}})();window.__tcfapi=function(){var a=Array.prototype.slice.call(arguments);if(!a.length)return q;if(a[0]==="setGdprApplies"){if(a.length>3&&parseInt(a[1],10)===2&&typeof a[3]==="boolean"){gdprApplies=a[3];if(typeof a[2]==="function")a[2]("set",true);}}else if(a[0]==="ping"){if(typeof a[2]==="function")a[2]({gdprApplies:gdprApplies,cmpLoaded:false,cmpStatus:"stub",apiVersion:"2.2"});}else{q.push(a);}};window.addEventListener("message",function(e){var s=typeof e.data==="string",d={};try{d=s?JSON.parse(e.data):e.data;}catch(x){}var c=d&&d.__tcfapiCall;if(c){window.__tcfapi(c.command,c.version,function(r,ok){var m={__tcfapiReturn:{returnValue:r,success:ok,callId:c.callId}};if(e.source)e.source.postMessage(s?JSON.stringify(m):m,"*");},c.parameter);}},false);})(); +(function(){if(typeof window.__tcfapi==="function")return;var n="__tcfapiLocator",w=window;while(w){try{if(w.frames[n])return;}catch(x){}if(w===window.top)break;w=w.parent;}var q=[],gdprApplies;(function add(){if(window.frames[n])return;if(document.body){var f=document.createElement("iframe");f.style.display="none";f.name=n;document.body.appendChild(f);}else{setTimeout(add,5);}})();window.__tcfapi=function(){var a=Array.prototype.slice.call(arguments);if(!a.length)return q;if(a[0]==="setGdprApplies"){if(a.length>3&&parseInt(a[1],10)===2&&typeof a[3]==="boolean"){gdprApplies=a[3];if(typeof a[2]==="function")a[2]("set",true);}}else if(a[0]==="ping"){if(typeof a[2]==="function")a[2]({gdprApplies:gdprApplies,cmpLoaded:false,cmpStatus:"stub",apiVersion:"2.2"});}else{q.push(a);}};window.addEventListener("message",function(e){var s=typeof e.data==="string",d={};try{d=s?JSON.parse(e.data):e.data;}catch(x){}var c=d&&d.__tcfapiCall;if(c){window.__tcfapi(c.command,c.version,function(r,ok){var m={__tcfapiReturn:{returnValue:r,success:ok,callId:c.callId}};if(e.source)e.source.postMessage(s?JSON.stringify(m):m,"*");},c.parameter);}},false);})(); diff --git a/crates/cmp/inmobi/tests/tcf_stub.test.mjs b/crates/cmp/inmobi/tests/tcf_stub.test.mjs index 80823bbe5d..0d94bfe7e1 100644 --- a/crates/cmp/inmobi/tests/tcf_stub.test.mjs +++ b/crates/cmp/inmobi/tests/tcf_stub.test.mjs @@ -9,8 +9,9 @@ import { test } from 'node:test'; const STUB = readFileSync(new URL('../src/tcf_stub.js', import.meta.url), 'utf8'); // A window and document holding only what the stub touches. `body` is false -// for a page whose body has not been parsed yet. -function browser({ body = true, tcfapi } = {}) { +// for a page whose body has not been parsed yet. `parent` and `top` are the +// windows above a page served inside a frame. +function browser({ body = true, tcfapi, parent, top } = {}) { const listeners = []; const appended = []; const timers = []; @@ -23,6 +24,10 @@ function browser({ body = true, tcfapi } = {}) { if (tcfapi) { window.__tcfapi = tcfapi; } + if (parent) { + window.parent = parent; + window.top = top ?? parent; + } const document = { body: body ? { @@ -170,3 +175,44 @@ test('a page that already has a __tcfapi keeps it', () => { assert.equal(page.listeners.length, 0, 'no relay is added'); assert.equal(page.appended.length, 0, 'no locator frame is added'); }); + +// A window above the page, with the frames it holds by name. +function ancestor(frames = {}) { + const window = { frames }; + window.parent = window; + window.top = window; + return window; +} + +test('a page inside a frame of a page that already has a CMP gets no second stub', () => { + const top = ancestor({ __tcfapiLocator: {} }); + const middle = { frames: {}, parent: top, top }; + const page = browser({ parent: middle, top }); + + assert.equal(page.window.__tcfapi, undefined, 'no __tcfapi is defined'); + assert.equal(page.listeners.length, 0, 'no relay is added'); + assert.equal(page.appended.length, 0, 'no locator frame is added'); + assert.equal(page.timers.length, 0, 'no locator frame is waited for'); +}); + +test('an ancestor that refuses to be read is passed over on the way up', () => { + const top = ancestor({ __tcfapiLocator: {} }); + const refusing = { + get frames() { + throw new Error('cross origin'); + }, + parent: top, + top, + }; + const page = browser({ parent: refusing, top }); + + assert.equal(page.window.__tcfapi, undefined, 'the CMP above it is found'); +}); + +test('a page inside a frame with no CMP above it gets the stub', () => { + const top = ancestor(); + const page = browser({ parent: top }); + + assert.equal(typeof page.window.__tcfapi, 'function'); + assert.equal(page.appended.length, 1, 'the locator frame is added'); +}); diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md index e221b9a801..a1061223d6 100644 --- a/docs/guide/integrations-overview.md +++ b/docs/guide/integrations-overview.md @@ -35,7 +35,7 @@ their operational procedures. | [`google_tag_manager`](/guide/integrations/google_tag_manager) | production | `[tag] modules = ["google-tag-manager"]` | settings builder | bundled | | [`gpt`](/guide/integrations/gpt) | production | `[ad-tag] modules = ["google"]` | settings builder | bundled | | [`gpt_diagnostics`](/guide/integrations/gpt-diagnostics) | development | `[ad-tag] modules = ["google.diagnostics"]` | settings builder | standalone | -| [`inmobi`](/guide/integrations/inmobi) | production | `[cmp] module = "inmobi"` | settings builder | none | +| [`inmobi`](/guide/integrations/inmobi) | development | `[cmp] module = "inmobi"` | settings builder | none | | `js_asset_proxy` (no dedicated guide) | development | `[proxy] modules = ["js_asset_proxy"]` | settings builder | none | | [`lockr`](/guide/integrations/lockr) | production | `[identity] module = "lockr"` | settings builder | bundled | | [`nextjs`](/guide/integrations/nextjs) | production | `[framework] module = "nextjs"` | settings builder | none | diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md index a8d01a7a6c..44b2101630 100644 --- a/docs/guide/integrations/inmobi.md +++ b/docs/guide/integrations/inmobi.md @@ -2,17 +2,16 @@ **Category**: Consent Management Platform -**Status**: Production +**Status**: Development **Type**: Consent prompt tag, with the IAB stubs ahead of it -**Maintainers**: 51Degrees, until InMobi adopt the crate (`crates/cmp/inmobi`) - ## Overview InMobi Choice is a TCF consent prompt loaded from the publisher's own InMobi account. The module writes the prompt's tag into the head of each page a -`[[fetch]]` entry names it for, in three parts and in this order: +`[[fetch]]` entry for `text/html` names it for, in three parts and in this +order: 1. The IAB TCF v2 stub, which creates the `__tcfapiLocator` frame, queues every `__tcfapi` call made before the prompt arrives, answers `ping` as a @@ -30,8 +29,9 @@ else of the page is changed, and the module serves no browser module of its own, because the two stubs and the Choice script are the whole client side. The prompt writes the reader's answer to the `euconsent-v2` cookie, which -the TCF permission signal module reads on the next request, so the answer -reaches the permission model the way every signal does. +core reads on the next request. The TCF permission signal module reads the +record core decodes from it, so the answer reaches the permission model the +way every signal does. ## Configuration @@ -61,12 +61,19 @@ The prompt changes a page only where a `[[fetch]]` entry names `cmp.inmobi`, in the fetch phase alone, because the tag is the same for every reader and belongs in the page every reader is served from. Core writes each middleware's head markup in the order the entry names them, and nothing -advertising related may run before the visitor answers the prompt, so name +advertising related may run before the reader answers the prompt, so name `cmp.inmobi` before any middleware that writes a vendor's tag, as the example above does ahead of Google Tag Manager. -Selecting the module 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 `text/html` entry naming it is refused when +the settings load and by `ts config validate`, because the prompt would +never be written. + +The tag is written at the start of the head, ahead of the page's own +``, and is about 3.6 KB. A browser looks for `` +only in the first 1024 bytes of a page, so the origin should send the +encoding in `Content-Type` as well, as `text/html; charset=utf-8`, or a page +that declares it only in the meta tag has its encoding guessed. ## What is checked From db43a8c06e3f9f7fa028600252f65fb18bfe3860 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:01:16 +0100 Subject: [PATCH 05/21] The consent prompt is refused where the entries leave a page without it The check that a selected prompt is placed passed when any [[fetch]] entry for text/html named cmp.inmobi. A response takes the first entry that covers it, so a prompt entry limited by path, with no entry for the rest of the site, served every other page with no prompt, and so did an earlier entry for part of the site that names other middleware. The check now walks the entries in order. Every entry up to the first that covers every path has to name the prompt, and such an entry has to exist, or the document is refused when the settings load and when the registry is built, with a message naming the entry and the pages left without the prompt. Tests: a prompt entry limited to /news/ and an earlier /news/ entry without the prompt are each refused naming the pages, and entries that put the prompt on every page, one entry for / among them, are accepted. --- crates/cmp/inmobi/src/lib.rs | 200 ++++++++++++++++++++++++++++++++--- 1 file changed, 183 insertions(+), 17 deletions(-) diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 5e65a83331..dfcaeae3e9 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -20,7 +20,7 @@ use validator::Validate; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; use trusted_server_core::middleware::{ - HTML_MEDIA_TYPE, Middleware, MiddlewareAction, MiddlewareContext, MiddlewarePhase, + HTML_MEDIA_TYPE, Middleware, MiddlewareAction, MiddlewareContext, MiddlewarePhase, PhaseEntry, }; use trusted_server_core::settings::{IntegrationConfig, Settings}; @@ -127,22 +127,60 @@ fn validate_script_url(url: &str) -> Result<(), Report> { Ok(()) } -/// Refuse a document that selects the prompt but names it in no entry for -/// `text/html`, where it would load and never be written. +/// Refuse a document that selects the prompt but leaves a page served as +/// `text/html` without it. +/// +/// A response takes the first entry that covers it, so every `text/html` +/// entry up to the first that covers every path has to name the prompt, and +/// such an entry has to exist. A path covers every page when the home page, +/// `/`, begins with it. fn require_an_entry(settings: &Settings) -> Result<(), Report> { - let placed = settings.fetch.entries().iter().any(|entry| { - entry.media_type == HTML_MEDIA_TYPE && entry.middleware.iter().any(|name| name == MODULE) - }); - if placed { - return Ok(()); - } - Err(Report::new(TrustedServerError::Settings { - message: format!( - "[cmp] selects `inmobi`, but no [[fetch]] entry for \"{HTML_MEDIA_TYPE}\" names \ - `{MODULE}`, so the consent prompt would never be written. Name it in that \ - entry, before any middleware that writes a vendor's tag" - ), - })) + let refusal = |gap: String| { + Report::new(TrustedServerError::Settings { + message: format!("[cmp] selects `inmobi`, but {gap}"), + }) + }; + let names_the_prompt = |entry: &PhaseEntry| entry.middleware.iter().any(|name| name == MODULE); + let entries = settings + .fetch + .entries() + .iter() + .enumerate() + .filter(|(_, entry)| entry.media_type == HTML_MEDIA_TYPE); + if !entries.clone().any(|(_, entry)| names_the_prompt(entry)) { + return Err(refusal(format!( + "no [[fetch]] entry for \"{HTML_MEDIA_TYPE}\" names `{MODULE}`, so the consent \ + prompt would never be written. Name it in that entry, before any middleware \ + that writes a vendor's tag" + ))); + } + let mut paths: Vec = Vec::new(); + for (index, entry) in entries { + let path = entry.path.as_deref().filter(|path| !"/".starts_with(path)); + if !names_the_prompt(entry) { + let pages = match path { + Some(path) => format!("the pages under `{path}`"), + None if paths.is_empty() => "every page".to_owned(), + None => format!("every page outside {}", paths.join(", ")), + }; + return Err(refusal(format!( + "[[fetch]] entry {} covers {pages} and does not name `{MODULE}`, so those \ + pages would be served with no consent prompt. Name it in that entry, before \ + any middleware that writes a vendor's tag", + index + 1 + ))); + } + match path { + Some(path) => paths.push(format!("`{path}`")), + None => return Ok(()), + } + } + Err(refusal(format!( + "the [[fetch]] entries for \"{HTML_MEDIA_TYPE}\" cover only the pages under {}, so \ + every other page, `/` among them, would be served with no consent prompt. Name \ + `{MODULE}` in an entry with no path as well, after the entries with one", + paths.join(", ") + ))) } /// Validates the `InMobi` Choice configuration and reports whether a section @@ -260,7 +298,7 @@ mod tests { }; use trusted_server_core::integrations::IntegrationDocumentState; use trusted_server_core::middleware::test_support::context; - use trusted_server_core::middleware::{MiddlewareChain, PhaseEntries, PhaseEntry}; + use trusted_server_core::middleware::{MiddlewareChain, PhaseEntries}; const REAL: &str = "https://cmp.inmobi.com/choice/EXAMPLEacct00/www.publisher.example/choice.js?tag_version=V3"; @@ -276,6 +314,56 @@ mod tests { )) } + /// A document selecting the prompt at [`REAL`], with `rest` written after + /// `script_url`, being more of `[cmp.inmobi]` and then the entries. + fn settings_with_rest(rest: &str) -> Settings { + page_settings(&format!( + "[cmp]\nmodule = \"inmobi\"\n\n[cmp.inmobi]\nscript_url = \"{REAL}\"\n{rest}" + )) + } + + /// A `[[fetch]]` entry for `text/html`, covering the pages under `path` + /// where one is given, that names `middleware` in order. + fn html_entry(path: Option<&str>, middleware: &[&str]) -> String { + let path = path + .map(|path| format!("path = \"{path}\"\n")) + .unwrap_or_default(); + format!( + "\n[[fetch]]\nmedia_type = \"text/html\"\n{path}middleware = {}\n", + serde_json::to_string(middleware).expect("a list") + ) + } + + /// Whether a page asked for at `path` gets the prompt from `settings`' + /// entries. + fn gets_the_prompt(settings: &Settings, path: &str) -> bool { + settings + .fetch + .for_response(HTML_MEDIA_TYPE, path) + .iter() + .any(|name| name == MODULE) + } + + /// Checks that `validate` and `register` both refuse `settings`, each + /// with a message that says every one of `reasons`. + fn assert_refused(settings: &Settings, reasons: &[&str]) { + for (call, refusal) in [ + ("validate", validate(settings).err()), + ("register", register(settings).err()), + ] { + let message = refusal + .unwrap_or_else(|| panic!("{call} should refuse the document")) + .current_context() + .to_string(); + for reason in reasons { + assert!( + message.contains(reason), + "{call} should say {reason:?}, and said {message:?}" + ); + } + } + } + /// The head markup `middleware` write in `phase`, in the order written. fn head_markup(phase: MiddlewarePhase, middleware: Vec>) -> Vec { let document_state = IntegrationDocumentState::default(); @@ -408,6 +496,84 @@ mod tests { assert!(register(&settings).is_err()); } + /// A prompt entry with a path, and no entry for the rest of the site, + /// leaves every other page with no prompt, so it is refused naming the + /// pages it leaves out. + #[test] + fn a_prompt_entry_limited_to_a_path_is_refused_naming_the_pages_left_out() { + let settings = settings_with_rest(&html_entry(Some("/news/"), &[MODULE])); + assert!( + gets_the_prompt(&settings, "/news/today") && !gets_the_prompt(&settings, "/"), + "the entry puts the prompt on the pages under /news/ alone" + ); + + assert_refused( + &settings, + &[ + "cover only the pages under `/news/`", + "`/` among them", + "with no consent prompt", + ], + ); + } + + /// A response takes the first entry that covers it, so an earlier entry + /// for part of the site that does not name the prompt leaves those pages + /// without it, whatever a later entry names. + #[test] + fn an_earlier_entry_without_the_prompt_is_refused_naming_its_pages() { + let settings = settings_with_rest(&format!( + "{}{}", + html_entry(Some("/news/"), &["vendor.tag"]), + html_entry(None, &[MODULE, "vendor.tag"]) + )); + assert!( + !gets_the_prompt(&settings, "/news/today") && gets_the_prompt(&settings, "/"), + "the pages under /news/ get the vendor's tag and no prompt" + ); + + assert_refused( + &settings, + &[ + "[[fetch]] entry 1 covers the pages under `/news/` and does not name \ + `cmp.inmobi`", + "with no consent prompt", + ], + ); + } + + /// Entries that each name the prompt, the last covering every path, put + /// it on every page and are accepted, and so is one entry for `/`. + #[test] + fn entries_that_put_the_prompt_on_every_page_are_accepted() { + for entries in [ + format!( + "{}{}", + html_entry(Some("/news/"), &[MODULE, "vendor.tag"]), + html_entry(None, &[MODULE]) + ), + html_entry(Some("/"), &[MODULE]), + ] { + let settings = settings_with_rest(&entries); + for path in ["/", "/news/today", "/sport/"] { + assert!( + gets_the_prompt(&settings, path), + "{path} should get the prompt from {entries}" + ); + } + assert!( + validate(&settings).expect("should accept the entries"), + "{entries}" + ); + assert!( + register(&settings) + .expect("should accept the entries") + .is_some(), + "{entries}" + ); + } + } + /// A script URL that should never reach a reader's browser is refused at /// load and at registration, with a message naming the section and the /// reason. From 9a50de77d1848dfa5fe32a77a331c1be969e7542 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:02:06 +0100 Subject: [PATCH 06/21] A CMP id outside 1 to 4095 is refused, naming cmp_id cmp_id was read as a u16, so 0 and every value up to 65535 were accepted, and a value of the wrong type was refused with a parse error that named the table but not the setting. A TC string holds the CMP id in 12 bits, so 4095 is the largest, and the GPP CMP API gives 0 as what a stub reports before it has an id. cmp_id is now read as a whole number from 1 to 4095, and any other value is refused when the settings load with a message that names cmp_id and the range. Test: 0, 4096, 70000, -1, a string and a fraction are each refused by the load validation, by validate and by register, naming the setting, and 1 and 4095 are accepted. --- crates/cmp/inmobi/src/lib.rs | 100 ++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index dfcaeae3e9..3bb735c219 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -63,18 +63,46 @@ pub struct InMobiChoiceConfig { /// because this becomes a `"), "{markup}"); } + /// A CMP id is a whole number the IAB assigned, which a TC string holds + /// in 12 bits, so any other value is refused when the settings load, with + /// a message naming the setting. + #[test] + fn a_cmp_id_outside_1_to_4095_is_refused_naming_the_setting() { + let with_cmp_id = |value: &str| { + settings_with_rest(&format!( + "cmp_id = {value}\n{}", + html_entry(None, &[MODULE]) + )) + }; + let load = |settings: &Settings| { + trusted_server_core::config::validate_settings_for_runtime_with( + settings, + &[super::builder()], + ) + }; + + // (case, the value as written, what the refusal must say) + let cases = [ + ( + "zero, which a stub reports before it has an id", + "0", + "`cmp_id` is 0,", + ), + ( + "past the 12 bits a TC string holds", + "4096", + "`cmp_id` is 4096,", + ), + ( + "past what the field could hold", + "70000", + "`cmp_id` is 70000,", + ), + ("below zero", "-1", "`cmp_id` is -1,"), + ("a string", "\"10\"", "`cmp_id` is not a whole number"), + ("a fraction", "10.5", "`cmp_id` is not a whole number"), + ]; + for (case, value, reason) in cases { + let settings = with_cmp_id(value); + for (call, refusal) in [ + ("loading the settings", load(&settings).err()), + ("validate", validate(&settings).err()), + ("register", register(&settings).err()), + ] { + let message = refusal + .unwrap_or_else(|| panic!("{case}: {call} should refuse cmp_id = {value}")) + .current_context() + .to_string(); + assert!( + message.contains("[cmp.inmobi]") && message.contains(reason), + "{case}: {call} should say {reason:?}, and said {message:?}" + ); + } + } + + for (value, id) in [("1", 1), ("4095", 4095)] { + let settings = with_cmp_id(value); + load(&settings).expect("should load a CMP id in range"); + let config = settings + .module_config::(MODULE) + .expect("should read the table") + .expect("should select the module"); + assert_eq!(config.cmp_id, id); + } + } + #[test] fn an_unknown_field_is_refused_rather_than_ignored() { let settings = page_settings(&format!( From 520ee6eefedb28ecece2554e5b94759d9e65f719 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:03:22 +0100 Subject: [PATCH 07/21] A middleware's own markup keys a stored page, so a stub change stores it again The fingerprint that keys a stored page covers the settings, the browser modules the registry serves and core's build, and not markup that a module crate compiles in. A change to the InMobi stubs alone left the fingerprint as it was, so a page stored before the change kept the old stubs until it expired. Middleware gains template_key, the text a stored page is keyed by, being what the middleware writes that no setting decides. It is empty by default. The fingerprint hashes each registered middleware's name and template_key, and the InMobi prompt gives its whole tag, so a change to either stub selects a new stored page. Tests: in core, a middleware whose compiled-in markup changes selects a different fingerprint and the same markup selects the same one, and in the InMobi crate the registered middleware's template_key holds both stubs. --- crates/cmp/inmobi/src/lib.rs | 27 +++++- .../src/integrations/registry.rs | 11 +++ crates/trusted-server-core/src/middleware.rs | 9 ++ crates/trusted-server-core/src/publisher.rs | 87 +++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 3bb735c219..13e0c8a5de 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -303,6 +303,12 @@ impl Middleware for Choice { ..MiddlewareAction::pass() } } + + /// The whole tag, because the stubs are compiled into this crate and no + /// setting changes when they do. + fn template_key(&self) -> &str { + &self.tag + } } /// Escapes a value for an HTML double quoted attribute. @@ -324,7 +330,7 @@ mod tests { use trusted_server_core::html_processor::test_support::{ assert_page_is_recorded, page_settings, }; - 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}; @@ -806,6 +812,25 @@ mod tests { ); } + /// The stubs are compiled into this crate and no setting changes when + /// they do, so they have to be in the text the registered middleware + /// keys a stored page by, or a page stored before a change to a stub + /// keeps the old stub until it expires. + #[test] + fn the_stubs_are_in_what_keys_a_stored_page() { + let settings = settings_with(REAL); + let registry = IntegrationRegistry::with_registrations(&settings, &[super::builder()]) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the prompt's middleware should be registered"); + assert!(key.contains(&tcf_stub()), "{key}"); + assert!(key.contains(&gpp_stub(CMP_ID)), "{key}"); + } + #[test] fn ampersands_and_quotes_in_the_url_are_escaped() { let tag = tag_in( diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index cae882cd60..a2c273691d 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1918,6 +1918,17 @@ impl IntegrationRegistry { .collect() } + /// Each middleware's name with the text it keys a stored page by, in + /// registration order. See [`Middleware::template_key`]. + #[must_use] + pub fn middleware_template_keys(&self) -> Vec<(&'static str, &str)> { + self.inner + .middleware + .iter() + .map(|(_, middleware)| (middleware.middleware_id(), middleware.template_key())) + .collect() + } + /// The middleware the first entry covering a response selects, in the /// order they run, for a response of `media_type` to a request for `path`. /// A response no entry covers gives an empty chain. diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index 9dacdf6e2f..71dc65ecd5 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -334,6 +334,15 @@ pub trait Middleware: Send + Sync { /// Decides what to do with one document. fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction; + + /// Text a stored page is keyed by, being what this middleware writes + /// that no setting decides, such as markup compiled into its crate. A + /// change to it stores pages again, where otherwise a page stored before + /// the change keeps the old markup until it expires. Empty when the + /// settings decide everything the middleware writes. + fn template_key(&self) -> &str { + "" + } } /// One entry of `[[fetch]]` or `[[serve]]`, being which responses it covers diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 8191f76566..7de3821715 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2234,6 +2234,10 @@ fn assemble_if_shared( /// serve. That is safe because a module this deployment never serves appears in no bundle /// hash and no injected URL, so it cannot change what a template renders. /// +/// Each registered middleware's [`template_key`](crate::middleware::Middleware::template_key) +/// covers what it writes that no setting decides, such as markup compiled into a module's +/// crate, which neither the build digest nor the settings see. +/// /// # Panics /// /// Does not panic: serializing the already-deserialized typed settings to a JSON value is @@ -2251,6 +2255,11 @@ fn template_fingerprint_with_build_digest( hasher.update(build_digest.as_bytes()); hasher .update(crate::tsjs_bundle::compose_hash(&integration_registry.js_parts_all()).as_bytes()); + // Each part is hashed alone, so no text can move where the next begins. + for (name, key) in integration_registry.middleware_template_keys() { + hasher.update(sha2::Sha256::digest(name.as_bytes())); + hasher.update(sha2::Sha256::digest(key.as_bytes())); + } // EdgeZero's canonical form sorts object keys itself, so independently deserialized // HashMaps hash identically even when a dependency enables `serde_json/preserve_order`. // Array order is preserved: set-valued settings must serialize deterministically @@ -10218,6 +10227,84 @@ mod tests { "a rebuilt carried module must select a different template" ); } + + /// Head markup a module's crate compiles in, before a fix to it. + const WRITTEN_BEFORE: &str = ""; + + /// The same markup after the fix. + const WRITTEN_AFTER: &str = ""; + + /// A fetch middleware that writes markup compiled into its crate, + /// which no setting decides. + struct WritesCompiledMarkup(&'static str); + + impl crate::middleware::Middleware for WritesCompiledMarkup { + fn middleware_id(&self) -> &'static str { + "testing.probe" + } + + fn phases(&self) -> &[crate::middleware::MiddlewarePhase] { + &[crate::middleware::MiddlewarePhase::Fetch] + } + + fn create( + &self, + _context: &crate::middleware::MiddlewareContext<'_>, + ) -> crate::middleware::MiddlewareAction { + crate::middleware::MiddlewareAction { + head_inserts: vec![self.0.to_owned()], + ..crate::middleware::MiddlewareAction::pass() + } + } + + fn template_key(&self) -> &str { + self.0 + } + } + + /// Registration of a middleware writing [`WRITTEN_BEFORE`]. + fn writing_before( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .without_js() + .with_middleware(std::sync::Arc::new(WritesCompiledMarkup(WRITTEN_BEFORE))) + .build(), + )) + } + + /// Registration of a middleware writing [`WRITTEN_AFTER`]. + fn writing_after( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("probe") + .without_js() + .with_middleware(std::sync::Arc::new(WritesCompiledMarkup(WRITTEN_AFTER))) + .build(), + )) + } + + #[test] + fn a_change_to_markup_a_middleware_compiles_in_changes_the_fingerprint() { + // A fetch middleware's markup is stored with the template, and no + // setting holds markup compiled into a module's crate, so a fix to + // it has to select a new template or stored pages keep the old one. + let mut settings = create_test_settings(); + settings.select_module("testing", "testing.probe"); + + assert_eq!( + fingerprint_with_carried(&settings, writing_before), + fingerprint_with_carried(&settings, writing_before), + "the same markup must select the same template" + ); + assert_ne!( + fingerprint_with_carried(&settings, writing_before), + fingerprint_with_carried(&settings, writing_after), + "a change to compiled-in markup must select a different template" + ); + } } mod seam_script_tests { From 010d3fcc4bd13f83a0a30607888b3a8443502c1d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:04:59 +0100 Subject: [PATCH 08/21] The guides say which entries and CMP ids are refused and what keys a stored page The InMobi guide, its section of the configuration guide and the changelog entry say that entries leaving any page without the prompt are refused, naming the pages, and that cmp_id is a whole number from 1 to 4095. The configuration guide's list of what keys a stored page, and the integration guide's section on middleware, name template_key. --- CHANGELOG.md | 2 +- docs/guide/configuration.md | 23 ++++++++++++----------- docs/guide/integration-guide.md | 4 ++++ docs/guide/integrations/inmobi.md | 20 +++++++++++--------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa47b5331e..8e7ecb5068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, and a selected prompt that no entry places is refused. The Trusted Server maintainers hold the crate until InMobi adopt it. +- The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, `cmp_id` unless it is a whole number from 1 to 4095, and entries that leave any page without the prompt are refused. The new `Middleware::template_key` keys a stored page by what a middleware writes that no setting decides, so a change to a stub stores pages again. The Trusted Server maintainers hold the crate until InMobi adopt it. - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module's answer is held in the key-value store until its refresh is due, a module that cannot answer has its last answer served, and with none held the answer is `503` and never an empty file. Without the section `/robots.txt` reaches the publisher's origin as before. - `ts keys rotate` and `ts keys deactivate` rotate and retire the request signing keys from an operator's machine. They write the two stores the service links as `jwks_store` and `signing_keys` through the `fastly` CLI, with the token from `FASTLY_API_TOKEN`. A private key reaches `fastly` on its standard input and never in its arguments, and a key id no platform can store is refused before anything is written. A command that cannot read the config store writes nothing, and a delete that failed part way can be run again. - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3fdcba7ac5..63c0f71c60 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1851,15 +1851,15 @@ See [Didomi](/guide/integrations/didomi) for the routed endpoint shapes. **Section**: `[cmp.inmobi]` -| Field | Type | Default | Contract | -| ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- | -| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | -| `cmp_id` | Number | `10` | The IAB registered CMP identifier 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 -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) +| Field | Type | Default | Contract | +| ------------ | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | +| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | +| `cmp_id` | Number | `10` | The IAB registered CMP identifier, from 1 to 4095, that the GPP stub answers `ping` with while the prompt loads | + +The table refuses a field it does not read. Selecting the module is refused +when the `[[fetch]]` entries leave any page without `cmp.inmobi`, because the +prompt would not be written there, and each entry names it before any +middleware that writes a vendor's tag. See [InMobi Choice](/guide/integrations/inmobi) for the tag's parts and what the module keeps to. ### Google Tag Manager Integration @@ -2691,8 +2691,9 @@ Check `X-TS-Template-Cache: hit` to verify template reuse. `template_cache_vary` is necessary because lookup occurs before the origin can return `Vary`. Presence, empty values, repeated raw field values, host/scheme, -origin identity, complete template-shaping settings, TSJS content, core build -inputs, and schema version all participate in an opaque SHA-256 cache key. The +origin identity, complete template-shaping settings, TSJS content, the text +each middleware gives as its `template_key`, core build inputs, and schema +version all participate in an opaque SHA-256 cache key. The build digest covers every core source file (including Rust-inlined head scripts) except hidden, `#`-prefixed, and `~`-suffixed editor artifacts, plus the core build script, core and workspace manifests, `edgezero.toml`, and the workspace diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index ac3fdabaac..169473b38b 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -431,6 +431,10 @@ that names it refuses startup. | `MiddlewarePhase::Fetch` | On the page as the origin sent it. What it leaves is what a shared template stores | Nothing a request left | | `MiddlewarePhase::Serve` | On each reader's copy, whether the page came from the store or from the origin | What the module's request hooks left for it | +A stored page is keyed by the settings and core's build, so a fetch +middleware that writes markup compiled into its own crate returns that +markup from `template_key`, and a change to it stores pages again. + A middleware is asked once for each document, by `create`, and what it answers is used for that document alone, so a handler may hold state between the chunks of one script. The answer is a `MiddlewareAction`, which may set diff --git a/docs/guide/integrations/inmobi.md b/docs/guide/integrations/inmobi.md index 44b2101630..c72e54c0f6 100644 --- a/docs/guide/integrations/inmobi.md +++ b/docs/guide/integrations/inmobi.md @@ -48,10 +48,10 @@ media_type = "text/html" middleware = ["cmp.inmobi", "tag.google-tag-manager"] ``` -| Field | Type | Default | Contract | -| ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- | -| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | -| `cmp_id` | Number | `10` | The IAB registered CMP identifier the GPP stub answers `ping` with while the prompt loads | +| Field | Type | Default | Contract | +| ------------ | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | +| `script_url` | URL | Required | The account's Choice loader, `https` on `cmp.inmobi.com` or `cdn.inmobi.com`, with no user name or password | +| `cmp_id` | Number | `10` | The IAB registered CMP identifier, from 1 to 4095, that the GPP stub answers `ping` with while the prompt loads | The table refuses a field it does not read. @@ -65,9 +65,11 @@ advertising related may run before the reader answers the prompt, so name `cmp.inmobi` before any middleware that writes a vendor's tag, as the example above does ahead of Google Tag Manager. -Selecting the module with no `text/html` entry naming it is refused when -the settings load and by `ts config validate`, because the prompt would -never be written. +Selecting the module is refused when the settings load and by `ts config +validate` if the entries leave any page served as `text/html` without the +prompt, and the refusal names those pages. A page takes the first entry that +covers it, so every entry names `cmp.inmobi`, and one with no `path` comes +after any that has one. The tag is written at the start of the head, ahead of the page's own ``, and is about 3.6 KB. A browser looks for `` @@ -103,8 +105,8 @@ following, and they are what every `[cmp]` module is held to. other path. 5. A script address written into every page is checked at load against what it is, not what it looks like. -6. A selected prompt that no entry places is refused rather than silently - left unwritten. +6. A selected prompt that the entries leave off any page is refused rather + than silently left unwritten. ## Tests From aeaccb8eb2318499590c2cef1b8985b4f375b0fa Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:14:32 +0100 Subject: [PATCH 09/21] The example settings say every entry names the prompt, and give the CMP id range --- trusted-server.example.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 3fbb18a07f..cde3f95bd7 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -772,9 +772,9 @@ auction_timeout_ms = 500 # is the whole configuration and it needs no table of its own. # InMobi Choice consent prompt. `script_url` is required, https on cmp.inmobi.com -# or cdn.inmobi.com with no user name or password. Name `cmp.inmobi` in the -# [[fetch]] entry before any middleware that writes a vendor's tag. Selected -# with [cmp] module = "inmobi". +# or cdn.inmobi.com with no user name or password, and `cmp_id` is 1 to 4095. +# Name `cmp.inmobi` in every [[fetch]] entry, before any middleware that writes +# a vendor's tag. Selected with [cmp] module = "inmobi". # [cmp.inmobi] # script_url = "https://cmp.inmobi.com/choice///choice.js?tag_version=V3" # cmp_id = 10 From c8942a67ca242d731b42ab56099dffb63b36d0cb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 13:54:43 +0100 Subject: [PATCH 10/21] Load the CMP id test's settings with an origin the load accepts The test's settings name the template's placeholder origin, which the load validation refuses before any module's own check, so its load leg failed on the origin rather than on cmp_id. A real origin stands in. --- crates/cmp/inmobi/src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 13e0c8a5de..3cde170d45 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -706,11 +706,15 @@ mod tests { /// a message naming the setting. #[test] fn a_cmp_id_outside_1_to_4095_is_refused_naming_the_setting() { + // The test settings name the template's placeholder origin, which the + // load refuses before any module's own check, so a real one stands in. let with_cmp_id = |value: &str| { - settings_with_rest(&format!( + let mut settings = settings_with_rest(&format!( "cmp_id = {value}\n{}", html_entry(None, &[MODULE]) - )) + )); + settings.publisher.origin_url = "https://origin.publisher.example".to_owned(); + settings }; let load = |settings: &Settings| { trusted_server_core::config::validate_settings_for_runtime_with( From 77775fc7d66498df50b7b77c47c8fde58e017dbd Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:35:36 +0100 Subject: [PATCH 11/21] The Google ad tag's bootstrap keys a stored page The Google ad tag writes the `tsjs.adInit` bootstrap into the head in the fetch phase, from `gpt_bootstrap.js` compiled into its crate, and points the GPT script's address at `/integrations/gpt/script`. Neither is in core's build digest or the settings, so a change to either left the template fingerprint as it was, and a page stored before the change kept the old bootstrap until it expired. The page change's `template_key` is now the head scripts as written, then the script path, built once when the module registers. The test `the_bootstrap_is_in_what_keys_a_stored_page` builds the registry from the recorded page's settings and finds the bootstrap and the script path in the key of the registered middleware. --- crates/ad-tag/google/src/lib.rs | 61 +++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index 8017393fc4..ecc6e529e7 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -99,6 +99,9 @@ const SECUREPUBADS_HOST: &str = "securepubads.g.doubleclick.net"; /// Integration route prefix for all GPT proxy endpoints. const ROUTE_PREFIX: &str = "/integrations/gpt"; +/// The first-party path a page loads the GPT script from. +const SCRIPT_PATH: &str = "/integrations/gpt/script"; + /// Configuration for the Google Publisher Tags integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] @@ -436,7 +439,7 @@ pub fn register( let mut registration = IntegrationRegistration::builder(GPT_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration.clone()))); + .with_middleware(Arc::new(PageChange::new(integration.clone()))); if let Some((name, value)) = integration.bundle_tag_attribute() { registration = registration.with_bundle_tag_attribute(name, value); } @@ -494,7 +497,21 @@ impl IntegrationProxy for GptIntegration { /// for. It writes the `tsjs.adInit` bootstrap into the head and, when /// `rewrite_script` is set, points the GPT script's address at the /// first-party path. -struct PageChange(Arc); +struct PageChange { + integration: Arc, + /// The head scripts as written, then [`SCRIPT_PATH`]. + template_key: String, +} + +impl PageChange { + fn new(integration: Arc) -> Self { + let template_key = integration.head_markup().concat() + SCRIPT_PATH; + Self { + integration, + template_key, + } + } +} impl Middleware for PageChange { fn middleware_id(&self) -> &'static str { @@ -507,16 +524,22 @@ impl Middleware for PageChange { fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { let mut action = MiddlewareAction { - head_inserts: self.0.head_markup(), + head_inserts: self.integration.head_markup(), ..MiddlewareAction::pass() }; - if self.0.config.rewrite_script { + if self.integration.config.rewrite_script { let decide: Rc = Rc::new(|matched| GptIntegration::rewrite_script_address(matched.value)); action.element_handlers = AttributeRewrite::each(&["src", "href"], &decide); } action } + + /// The head scripts and the script path, because the bootstrap and the + /// path are compiled into this crate and no setting changes when they do. + fn template_key(&self) -> &str { + &self.template_key + } } impl GptIntegration { @@ -529,7 +552,7 @@ impl GptIntegration { // so on pages that set an external base URL these resolve against // that base rather than the address-bar origin — an accepted // tradeoff, matching GTM/Didomi/Testlight which are also relative. - AttributeRewriteAction::replace("/integrations/gpt/script".to_string()) + AttributeRewriteAction::replace(SCRIPT_PATH.to_string()) } else { AttributeRewriteAction::keep() } @@ -640,7 +663,7 @@ mod tests { MiddlewarePhase::Fetch, &document_state, ); - PageChange(integration).create(&context) + PageChange::new(integration).create(&context) } fn build_http_request(method: Method, uri: &str) -> http::Request { @@ -1440,7 +1463,31 @@ mod tests { #[test] fn the_middleware_is_named_as_the_module() { let integration = GptIntegration::new(test_config()); - assert_eq!(PageChange(integration).middleware_id(), MODULE); + assert_eq!(PageChange::new(integration).middleware_id(), MODULE); + } + + /// The bootstrap and the script path are compiled into this crate and no + /// setting changes when they do, so they have to be in the text the + /// registered middleware keys a stored page by, or a page stored before a + /// change to either keeps the old one until it expires. + #[test] + fn the_bootstrap_is_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[super::builder(), super::diagnostics::builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + assert!(key.contains(GPT_BOOTSTRAP_JS), "{key}"); + assert!(key.contains(SCRIPT_PATH), "{key}"); } #[test] From 46d2c4d76dc996ab22f384fa05f70edb7cd72905 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:37:29 +0100 Subject: [PATCH 12/21] Prebid's head scripts key a stored page Prebid's page change writes two scripts into the head in the fetch phase, being the one that hands Prebid.js its configuration and the tag that loads the bundle. Both are made by code compiled into its crate, so a change to either left the template fingerprint as it was and a page stored before the change kept the old scripts until it expired. The page change's `template_key` is now its head scripts as written, built once when the module registers. The test `the_head_scripts_are_in_what_keys_a_stored_page` plans the recorded page's fetch entry and finds every script the middleware writes into the head in the key of the registered middleware. --- crates/auction/prebid/src/lib.rs | 74 +++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/auction/prebid/src/lib.rs b/crates/auction/prebid/src/lib.rs index bebe707333..b08e2c82de 100644 --- a/crates/auction/prebid/src/lib.rs +++ b/crates/auction/prebid/src/lib.rs @@ -1103,7 +1103,7 @@ pub fn register_for_plan( Ok(Some( IntegrationRegistration::builder(PREBID_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration))) + .with_middleware(Arc::new(PageChange::new(integration))) .with_deferred_js() .build(), )) @@ -1121,7 +1121,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(PREBID_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration))) + .with_middleware(Arc::new(PageChange::new(integration))) .with_deferred_js() .build(), )) @@ -1191,7 +1191,21 @@ impl IntegrationProxy for PrebidIntegration { /// [`MODULE`] for. It writes the configuration Prebid.js reads and the tag /// that loads the bundle into the head, and removes the publisher's own /// Prebid script. -struct PageChange(Arc); +struct PageChange { + integration: Arc, + /// The head scripts as written. + template_key: String, +} + +impl PageChange { + fn new(integration: Arc) -> Self { + let template_key = integration.head_markup().concat(); + Self { + integration, + template_key, + } + } +} impl Middleware for PageChange { fn middleware_id(&self) -> &'static str { @@ -1203,15 +1217,22 @@ impl Middleware for PageChange { } fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { - let integration = Arc::clone(&self.0); + let integration = Arc::clone(&self.integration); let decide: Rc = Rc::new(move |matched| integration.rewrite_script_address(matched.value)); MiddlewareAction { - head_inserts: self.0.head_markup(), + head_inserts: self.integration.head_markup(), element_handlers: AttributeRewrite::each(&["src", "href"], &decide), ..MiddlewareAction::pass() } } + + /// The head scripts, because the script that hands Prebid.js its + /// configuration and the tag that loads the bundle are compiled into this + /// crate and no setting changes when they do. + fn template_key(&self) -> &str { + &self.template_key + } } impl PrebidIntegration { @@ -3379,6 +3400,49 @@ server_url = "https://prebid.example" ); } + /// The script that hands Prebid.js its configuration and the tag that + /// loads the bundle are compiled into this crate and no setting changes + /// when they do, so what the registered middleware writes into the head + /// has to be in the text it keys a stored page by, or a page stored before + /// a change keeps the old scripts until it expires. + #[test] + fn the_head_scripts_are_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + let document_state = IntegrationDocumentState::default(); + let written = registry + .middleware_chain( + &settings.fetch, + MiddlewarePhase::Fetch, + trusted_server_core::middleware::HTML_MEDIA_TYPE, + "/", + ) + .plan(&trusted_server_core::middleware::test_support::context( + MiddlewarePhase::Fetch, + &document_state, + )) + .expect("should plan the page change") + .head_inserts; + assert!( + written + .iter() + .any(|script| script.contains("window.__tsjs_prebid=")), + "should write the configuration script: {written:?}" + ); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + for script in &written { + assert!(key.contains(script.as_str()), "{script} is not in {key}"); + } + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. From 180e66ceb8b561893b8a62a20feb0a8158ea9baf Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:38:32 +0100 Subject: [PATCH 13/21] Didomi's settings script keys a stored page Didomi's middleware writes a script into the head in the fetch phase that hands the browser module the path Didomi is served under. The script's text, and the default path when the settings name none, are compiled into its crate, so a change to either left the template fingerprint as it was and a page stored before the change kept the old script until it expired. The script is now made once when the module registers, written from that copy and returned as the middleware's `template_key`. The test `the_settings_script_is_in_what_keys_a_stored_page` plans the recorded page's fetch entry, checks the default path is written, and finds every script the middleware writes into the head in the key of the registered middleware. --- crates/cmp/didomi/src/lib.rs | 68 +++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 87dae6359e..84a1ebbab5 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -439,7 +439,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(DIDOMI_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(ClientConfig(integration))) + .with_middleware(Arc::new(ClientConfig::new(&integration))) .build(), )) } @@ -595,7 +595,18 @@ impl DidomiIntegration { /// Writes the browser module's settings into the head, on the pages a /// `[[fetch]]` entry names [`MODULE`] for. -struct ClientConfig(Arc); +struct ClientConfig { + /// The script, made once when the module registers. + script: String, +} + +impl ClientConfig { + fn new(integration: &DidomiIntegration) -> Self { + Self { + script: integration.client_config_script(), + } + } +} impl Middleware for ClientConfig { fn middleware_id(&self) -> &'static str { @@ -608,10 +619,16 @@ impl Middleware for ClientConfig { fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { MiddlewareAction { - head_inserts: vec![self.0.client_config_script()], + head_inserts: vec![self.script.clone()], ..MiddlewareAction::pass() } } + + /// The script, because its text and the default path in it are compiled + /// into this crate and no setting changes when they do. + fn template_key(&self) -> &str { + &self.script + } } #[cfg(test)] @@ -1385,7 +1402,7 @@ mod tests { &document_state, ); - let action = ClientConfig(integration).create(&context); + let action = ClientConfig::new(&integration).create(&context); assert_eq!( action.head_inserts, @@ -1478,6 +1495,49 @@ mod tests { ); } + /// The settings script and its default path are compiled into this crate + /// and no setting changes when they do, so what the registered middleware + /// writes into the head has to be in the text it keys a stored page by, + /// or a page stored before a change keeps the old script until it + /// expires. + #[test] + fn the_settings_script_is_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + let document_state = trusted_server_core::integrations::IntegrationDocumentState::default(); + let written = registry + .middleware_chain( + &settings.fetch, + MiddlewarePhase::Fetch, + trusted_server_core::middleware::HTML_MEDIA_TYPE, + "/", + ) + .plan(&trusted_server_core::middleware::test_support::context( + MiddlewarePhase::Fetch, + &document_state, + )) + .expect("should plan the page change") + .head_inserts; + assert!( + written + .iter() + .any(|script| script.contains(DIDOMI_DEFAULT_PREFIX)), + "should write the default path: {written:?}" + ); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the settings script's middleware should be registered"); + for script in &written { + assert!(key.contains(script.as_str()), "{script} is not in {key}"); + } + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. From 0296a7e7e895bda77b00f7d4298f04946c174624 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:39:31 +0100 Subject: [PATCH 14/21] Sourcepoint's trap and first-party path key a stored page Sourcepoint's page change writes the browser module's settings into the head in the fetch phase and, when `rewrite_sdk` is set, a trap on `window._sp_`, and points the CDN's addresses at `/integrations/sourcepoint/cdn`. The trap and the path are compiled into its crate, so a change to either left the template fingerprint as it was and a page stored before the change kept the old trap until it expired. The page change's `template_key` is now its head scripts as written, then the first-party path, built once when the module registers. The test `the_trap_and_the_path_are_in_what_keys_a_stored_page` plans the recorded page's fetch entry, checks the trap is written, and finds every script the middleware writes into the head, and the path, in the key of the registered middleware. --- crates/cmp/sourcepoint/src/lib.rs | 77 ++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index f1d64cdae2..7e36130464 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -806,7 +806,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(SOURCEPOINT_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_middleware(Arc::new(PageChange(integration))) + .with_middleware(Arc::new(PageChange::new(integration))) .build(), )) } @@ -1036,7 +1036,21 @@ impl IntegrationProxy for SourcepointIntegration { /// [`MODULE`] for. It writes the browser module's settings into the head and, /// when `rewrite_sdk` is set, the trap on `window._sp_`, and points the /// CDN's addresses at the first-party path. -struct PageChange(Arc); +struct PageChange { + integration: Arc, + /// The head scripts as written, then [`SOURCEPOINT_CDN_PREFIX`]. + template_key: String, +} + +impl PageChange { + fn new(integration: Arc) -> Self { + let template_key = integration.head_markup().concat() + SOURCEPOINT_CDN_PREFIX; + Self { + integration, + template_key, + } + } +} impl Middleware for PageChange { fn middleware_id(&self) -> &'static str { @@ -1049,17 +1063,24 @@ impl Middleware for PageChange { fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { let mut action = MiddlewareAction { - head_inserts: self.0.head_markup(), + head_inserts: self.integration.head_markup(), ..MiddlewareAction::pass() }; - if self.0.config.rewrite_sdk { - let integration = Arc::clone(&self.0); + if self.integration.config.rewrite_sdk { + let integration = Arc::clone(&self.integration); let decide: Rc = Rc::new(move |matched| integration.rewrite_sdk_address(matched.value)); action.element_handlers = AttributeRewrite::each(&["src", "href"], &decide); } action } + + /// The head scripts and the first-party path, because the trap on + /// `window._sp_` and the path are compiled into this crate and no setting + /// changes when they do. + fn template_key(&self) -> &str { + &self.template_key + } } impl SourcepointIntegration { @@ -1468,7 +1489,7 @@ mod tests { &document_state, ); - let action = PageChange(integration).create(&context); + let action = PageChange::new(integration).create(&context); assert!( action.element_handlers.is_empty(), @@ -2205,6 +2226,50 @@ mod tests { ); } + /// The trap on `window._sp_` and the first-party path are compiled into + /// this crate and no setting changes when they do, so what the registered + /// middleware writes into the head, and the path, have to be in the text + /// it keys a stored page by, or a page stored before a change keeps the + /// old trap until it expires. + #[test] + fn the_trap_and_the_path_are_in_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + let document_state = trusted_server_core::integrations::IntegrationDocumentState::default(); + let written = registry + .middleware_chain( + &settings.fetch, + MiddlewarePhase::Fetch, + trusted_server_core::middleware::HTML_MEDIA_TYPE, + "/", + ) + .plan(&trusted_server_core::middleware::test_support::context( + MiddlewarePhase::Fetch, + &document_state, + )) + .expect("should plan the page change") + .head_inserts; + assert!( + written + .iter() + .any(|script| script.contains("Object.defineProperty(window,\"_sp_\"")), + "should write the trap: {written:?}" + ); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + for script in &written { + assert!(key.contains(script.as_str()), "{script} is not in {key}"); + } + assert!(key.contains(SOURCEPOINT_CDN_PREFIX), "{key}"); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. From 982e4077e5b9bf420fe972a33eb3aac6b03cee35 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:40:35 +0100 Subject: [PATCH 15/21] Permutive's SDK path keys a stored page Permutive's middleware points the SDK's address at `/integrations/permutive/sdk` in the fetch phase. The path is compiled into its crate, so a change to it left the template fingerprint as it was and a page stored before the change kept loading the SDK from the old path until it expired. The path is now one constant, which the rewrite writes and the middleware's `template_key` returns. The test `the_sdk_path_is_what_keys_a_stored_page` finds the path as the key of the registered middleware, and in the recorded page. --- crates/audience/permutive/src/lib.rs | 39 +++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/audience/permutive/src/lib.rs b/crates/audience/permutive/src/lib.rs index f2e35fd6bc..ee3a4fd5e1 100644 --- a/crates/audience/permutive/src/lib.rs +++ b/crates/audience/permutive/src/lib.rs @@ -462,6 +462,9 @@ impl IntegrationProxy for PermutiveIntegration { } } +/// The first-party path a page loads the SDK from. +const SDK_PATH: &str = "/integrations/permutive/sdk"; + impl PermutiveIntegration { /// What becomes of a `src` or an `href`, which is pointed at the /// first-party endpoint when it is the SDK's address. @@ -473,7 +476,7 @@ impl PermutiveIntegration { // so on pages that set an external base URL these resolve against // that base rather than the address-bar origin — an accepted // tradeoff, matching GTM/Didomi/Testlight which are also relative. - AttributeRewriteAction::replace("/integrations/permutive/sdk".to_string()) + AttributeRewriteAction::replace(SDK_PATH.to_string()) } else { AttributeRewriteAction::keep() } @@ -505,6 +508,12 @@ impl Middleware for SdkAddress { ..MiddlewareAction::pass() } } + + /// The SDK's path, because it is compiled into this crate and no setting + /// changes when it does. + fn template_key(&self) -> &str { + SDK_PATH + } } // Default value functions @@ -761,6 +770,34 @@ mod tests { ); } + /// The SDK's path is compiled into this crate and no setting changes when + /// it does, so it has to be the text the registered middleware keys a + /// stored page by, or a page stored before a change keeps loading the SDK + /// from the old path until it expires. + #[test] + fn the_sdk_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the SDK's middleware should be registered"); + assert_eq!(*key, SDK_PATH); + assert!( + include_str!("fixtures/page-change.recorded.html") + .contains(&format!("src=\"{SDK_PATH}\"")), + "the recorded page should load the SDK from the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. From 2c13c6c3f8a2f2953272a30a452560548850c1b0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:41:12 +0100 Subject: [PATCH 16/21] Lockr's SDK path keys a stored page Lockr's middleware points the SDK's address at `/integrations/lockr/sdk` in the fetch phase. The path is compiled into its crate, so a change to it left the template fingerprint as it was and a page stored before the change kept loading the SDK from the old path until it expired. The path is now one constant, which the rewrite writes and the middleware's `template_key` returns. The test `the_sdk_path_is_what_keys_a_stored_page` finds the path as the key of the registered middleware, and in the recorded page. --- crates/identity/lockr/src/lib.rs | 39 +++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/identity/lockr/src/lib.rs b/crates/identity/lockr/src/lib.rs index 2ca3601642..0135b4b698 100644 --- a/crates/identity/lockr/src/lib.rs +++ b/crates/identity/lockr/src/lib.rs @@ -423,6 +423,9 @@ impl IntegrationProxy for LockrIntegration { } } +/// The first-party path a page loads the SDK from. +const SDK_PATH: &str = "/integrations/lockr/sdk"; + impl LockrIntegration { /// What becomes of a `src` or an `href`, which is pointed at the /// first-party endpoint when it is the SDK's address. @@ -433,7 +436,7 @@ impl LockrIntegration { // so on pages that set an external base URL these resolve against // that base rather than the address-bar origin — an accepted // tradeoff, matching GTM/Didomi/Testlight which are also relative. - let replacement = "/integrations/lockr/sdk".to_string(); + let replacement = SDK_PATH.to_string(); log::debug!("Rewriting Lockr SDK URL to {}", replacement); AttributeRewriteAction::Replace(replacement) } else { @@ -467,6 +470,12 @@ impl Middleware for SdkAddress { ..MiddlewareAction::pass() } } + + /// The SDK's path, because it is compiled into this crate and no setting + /// changes when it does. + fn template_key(&self) -> &str { + SDK_PATH + } } fn default_api_endpoint() -> String { @@ -765,6 +774,34 @@ mod tests { ); } + /// The SDK's path is compiled into this crate and no setting changes when + /// it does, so it has to be the text the registered middleware keys a + /// stored page by, or a page stored before a change keeps loading the SDK + /// from the old path until it expires. + #[test] + fn the_sdk_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the SDK's middleware should be registered"); + assert_eq!(*key, SDK_PATH); + assert!( + include_str!("fixtures/page-change.recorded.html") + .contains(&format!("src=\"{SDK_PATH}\"")), + "the recorded page should load the SDK from the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. From 9a4b4e49ff4b7aea26212c758a1ce30b09023504 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:42:02 +0100 Subject: [PATCH 17/21] DataDome's SDK path keys a stored page DataDome's fetch middleware points the SDK's script addresses under `/integrations/datadome`. The path is compiled into its crate, so a change to it left the template fingerprint as it was and a page stored before the change kept loading the SDK from the old path until it expired. The serve middleware writes the client tag into each reader's copy, which is never stored, so it keys nothing. The path is now one constant, which the rewrite writes under and the fetch middleware's `template_key` returns. The test `the_sdk_path_is_what_keys_a_stored_page` finds the path as the key of the registered fetch middleware, and in the recorded page. --- crates/bot-protection/datadome/src/lib.rs | 40 ++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/bot-protection/datadome/src/lib.rs b/crates/bot-protection/datadome/src/lib.rs index 34be512da5..a98f9a6756 100644 --- a/crates/bot-protection/datadome/src/lib.rs +++ b/crates/bot-protection/datadome/src/lib.rs @@ -970,6 +970,9 @@ impl Middleware for ClientTag { } } +/// The first-party path a page loads the SDK's scripts under. +const SDK_PREFIX: &str = "/integrations/datadome"; + /// Loads the `DataDome` SDK from the first-party path, on the pages a /// `[[fetch]]` entry names [`MODULE`] for. It does nothing unless /// `rewrite_sdk` is set. @@ -995,6 +998,12 @@ impl Middleware for SdkAddress { ..MiddlewareAction::pass() } } + + /// The path the SDK's scripts are loaded under, because it is compiled + /// into this crate and no setting changes when it does. + fn template_key(&self) -> &str { + SDK_PREFIX + } } impl DataDomeIntegration { @@ -1049,7 +1058,7 @@ impl DataDomeIntegration { // on pages that set an external base URL these resolve against that base // rather than the address-bar origin — an accepted tradeoff, matching // GTM/Didomi/Testlight which are also relative. - let new_url = format!("/integrations/datadome{path}"); + let new_url = format!("{SDK_PREFIX}{path}"); log::info!( "[datadome] Rewriting script src from {} to {}", @@ -2089,6 +2098,35 @@ mod tests { ); } + /// The path the SDK's scripts are loaded under is compiled into this crate + /// and no setting changes when it does, so it has to be the text the + /// registered fetch middleware keys a stored page by, or a page stored + /// before a change keeps loading the SDK from the old path until it + /// expires. + #[test] + fn the_sdk_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = trusted_server_core::integrations::IntegrationRegistry::with_registrations( + &settings, + &[builder()], + ) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the SDK's middleware should be registered"); + assert_eq!(*key, SDK_PREFIX); + assert!( + include_str!("fixtures/page-change.recorded.html") + .contains(&format!("src=\"{SDK_PREFIX}/tags.js\"")), + "the recorded page should load the SDK under the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. From 5e356d5e106af3528186545499d48128592316d5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:43:16 +0100 Subject: [PATCH 18/21] Google Tag Manager's first-party path keys a stored page Google Tag Manager's page change points Google's tag addresses under `/integrations/google_tag_manager` in the fetch phase, in `src` and `href` attributes and in inline scripts. The path is compiled into its crate, so a change to it left the template fingerprint as it was and a page stored before the change kept the old addresses until it expired. The path is now made once, which the rewrite writes under and the middleware's `template_key` returns. The test `the_first_party_path_is_what_keys_a_stored_page` finds the path as the key of the registered middleware, and in the recorded page in an attribute and in an inline snippet. --- crates/tag/google-tag-manager/src/lib.rs | 42 +++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index 023c0449ae..b15c9e74bb 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -53,6 +53,10 @@ use trusted_server_core::settings::{IntegrationConfig, Settings}; const GTM_INTEGRATION_ID: &str = "google_tag_manager"; +/// The first-party path Google's tag addresses are pointed under. +static FIRST_PARTY_PREFIX: LazyLock = + LazyLock::new(|| format!("/integrations/{GTM_INTEGRATION_ID}")); + /// The name this module is selected by, in `[tag]`. pub const MODULE: &str = "tag.google-tag-manager"; @@ -439,7 +443,7 @@ impl GoogleTagManagerIntegration { /// all variants (https, protocol-relative) of `googletagmanager.com`, /// `google-analytics.com` and `analytics.google.com`. fn rewrite_gtm_urls(content: &str) -> String { - let first_party = format!("/integrations/{GTM_INTEGRATION_ID}"); + let first_party = FIRST_PARTY_PREFIX.as_str(); // An attribute value is the whole URL on its own. if let Some(captures) = GTM_WHOLE_URL_PATTERN.captures(content) { @@ -1064,6 +1068,12 @@ impl Middleware for GoogleTagManagerIntegration { ..MiddlewareAction::pass() } } + + /// The path Google's tag addresses are pointed under, because it is + /// compiled into this crate and no setting changes when it does. + fn template_key(&self) -> &str { + FIRST_PARTY_PREFIX.as_str() + } } /// Rewrites Google's tag addresses in the text of inline scripts. @@ -3964,6 +3974,36 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= ); } + /// The path Google's tag addresses are pointed under is compiled into + /// this crate and no setting changes when it does, so it has to be the + /// text the registered middleware keys a stored page by, or a page stored + /// before a change keeps the old addresses until it expires. + #[test] + fn the_first_party_path_is_what_keys_a_stored_page() { + let settings = trusted_server_core::html_processor::test_support::page_settings( + include_str!("fixtures/page-change.settings.toml"), + ); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == MODULE) + .expect("the page change should be registered"); + assert_eq!(*key, FIRST_PARTY_PREFIX.as_str()); + let recorded = include_str!("fixtures/page-change.recorded.html"); + let prefix = FIRST_PARTY_PREFIX.as_str(); + assert!( + recorded.contains(&format!("src=\"{prefix}/gtag/js?id=G-TEST1234\"")), + "the recorded page should point an attribute under the path the key holds" + ); + assert!( + recorded.contains(&format!("'{prefix}/gtm.js?id='")), + "the recorded page should point an inline snippet under the path the key holds" + ); + } + /// The page a reader receives with this module running, kept as a file /// so that changing how the page change is made can be shown to leave /// the page as it was. From e3ed24bce75b1d25bfeade7d35930d0c8cffc650 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:44:26 +0100 Subject: [PATCH 19/21] The seam probe's head marker and report path key a stored page The seam probe's fetch middleware writes a marker into the head and points its links at `SEAM_PROBE_REPORT_PATH`, text compiled into its crate. The probe never ships, but it stands in for a module outside core, so it keys a stored page by that text as a module does. The fetch middleware's `template_key` is now the head marker as written, then the report path, built once when the probe registers. The test `the_marker_and_the_report_path_are_in_what_keys_a_stored_page` finds both in the key of the registered fetch middleware. --- crates/testing/seam-probe/src/lib.rs | 42 ++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 9354d50462..6a3ebc5fdd 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -168,6 +168,18 @@ pub const SEAM_PROBE_SCRIPT_WORD: &str = "SEAM_PROBE_COUNTRY"; /// that carries `data-seam-probe`. pub struct SeamProbeMiddleware { country: String, + /// The head marker as written, then [`SEAM_PROBE_REPORT_PATH`]. + template_key: String, +} + +impl SeamProbeMiddleware { + fn new(country: String) -> Self { + let template_key = seam_probe_head_marker(&country) + SEAM_PROBE_REPORT_PATH; + Self { + country, + template_key, + } + } } impl Middleware for SeamProbeMiddleware { @@ -194,6 +206,12 @@ impl Middleware for SeamProbeMiddleware { ..MiddlewareAction::pass() } } + + /// The head marker and the report path, because their text is compiled + /// into this crate and no setting changes when it does. + fn template_key(&self) -> &str { + &self.template_key + } } /// The name an entry gives the probe's serve middleware, which is the @@ -670,9 +688,7 @@ pub fn register( source: PROBE_JS, sha256: PROBE_JS_SHA256, }) - .with_middleware(Arc::new(SeamProbeMiddleware { - country: config.country.clone(), - })) + .with_middleware(Arc::new(SeamProbeMiddleware::new(config.country.clone()))) .with_middleware(Arc::new(SeamProbeReaderMiddleware)); if config.declares_geo { registration = registration @@ -1133,6 +1149,26 @@ mod tests { ); } + /// The head marker's markup and the report path are compiled into this + /// crate and no setting changes when they do, so they have to be in the + /// text the registered fetch middleware keys a stored page by. + #[test] + fn the_marker_and_the_report_path_are_in_what_keys_a_stored_page() { + use trusted_server_core::integrations::IntegrationRegistry; + + let settings = settings_with_probe("country = \"NZ\""); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build a registry with the probe registered"); + + let keys = registry.middleware_template_keys(); + let (_, key) = keys + .iter() + .find(|(name, _)| *name == module_name()) + .expect("the probe's fetch middleware should be registered"); + assert!(key.contains(&seam_probe_head_marker("NZ")), "{key}"); + assert!(key.contains(SEAM_PROBE_REPORT_PATH), "{key}"); + } + #[tokio::test] async fn geo_module_resolves_the_configured_country() { let module = SeamProbeGeo::new("ZZ".to_string()); From 9ad63b73e0b59ed2491a66c5c46ff01ed88d60c8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:46:17 +0100 Subject: [PATCH 20/21] The changelog and the integration guide say which middleware key a stored page The changelog names the stock modules whose fetch middleware now give the text compiled into their crates as their `template_key`, and what each gives. The integration guide says a fetch middleware returns an address compiled into its crate as well as markup. --- CHANGELOG.md | 1 + docs/guide/integration-guide.md | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7ecb5068..8cee042b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, `cmp_id` unless it is a whole number from 1 to 4095, and entries that leave any page without the prompt are refused. The new `Middleware::template_key` keys a stored page by what a middleware writes that no setting decides, so a change to a stub stores pages again. The Trusted Server maintainers hold the crate until InMobi adopt it. +- The stock modules' fetch middleware give as their `template_key` the text compiled into their crates that they write into a page, so a change to it stores pages again. The Google ad tag gives its head scripts, the `tsjs.adInit` bootstrap among them, and the GPT script's first-party path, Prebid its configuration script and bundle tag, Didomi its settings script, and Sourcepoint its head scripts and its CDN's first-party path. Permutive, Lockr, DataDome and Google Tag Manager give the first-party path they point a vendor's address at. - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module's answer is held in the key-value store until its refresh is due, a module that cannot answer has its last answer served, and with none held the answer is `503` and never an empty file. Without the section `/robots.txt` reaches the publisher's origin as before. - `ts keys rotate` and `ts keys deactivate` rotate and retire the request signing keys from an operator's machine. They write the two stores the service links as `jwks_store` and `signing_keys` through the `fastly` CLI, with the token from `FASTLY_API_TOKEN`. A private key reaches `fastly` on its standard input and never in its arguments, and a key id no platform can store is refused before anything is written. A command that cannot read the config store writes nothing, and a delete that failed part way can be run again. - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 169473b38b..d27cf0b1a6 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -432,8 +432,9 @@ that names it refuses startup. | `MiddlewarePhase::Serve` | On each reader's copy, whether the page came from the store or from the origin | What the module's request hooks left for it | A stored page is keyed by the settings and core's build, so a fetch -middleware that writes markup compiled into its own crate returns that -markup from `template_key`, and a change to it stores pages again. +middleware that writes markup or an address compiled into its own crate +returns that text from `template_key`, and a change to it stores pages +again. A middleware is asked once for each document, by `create`, and what it answers is used for that document alone, so a handler may hold state between From 45ccfd1bfdf0f9087ed14f172d054fc40971e928 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 18:55:54 +0100 Subject: [PATCH 21/21] The CMP id test expects the setting named and its value never quoted From this branch a refused module table names the setting at fault and never its value, so loading settings with an out of range cmp_id says that cmp_id in [cmp.inmobi] holds a value of the wrong type or form, and no longer the range message the module's reader gives, which quotes the value. The InMobi test of the CMP id range checks each of its six values for the table and the setting, and that the value is not quoted. --- crates/cmp/inmobi/src/lib.rs | 38 ++++++++++++++---------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index 3cde170d45..d036d38f9e 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -703,7 +703,7 @@ mod tests { /// A CMP id is a whole number the IAB assigned, which a TC string holds /// in 12 bits, so any other value is refused when the settings load, with - /// a message naming the setting. + /// a message naming the setting and never its value. #[test] fn a_cmp_id_outside_1_to_4095_is_refused_naming_the_setting() { // The test settings name the template's placeholder origin, which the @@ -723,28 +723,16 @@ mod tests { ) }; - // (case, the value as written, what the refusal must say) + // (case, the value as written) let cases = [ - ( - "zero, which a stub reports before it has an id", - "0", - "`cmp_id` is 0,", - ), - ( - "past the 12 bits a TC string holds", - "4096", - "`cmp_id` is 4096,", - ), - ( - "past what the field could hold", - "70000", - "`cmp_id` is 70000,", - ), - ("below zero", "-1", "`cmp_id` is -1,"), - ("a string", "\"10\"", "`cmp_id` is not a whole number"), - ("a fraction", "10.5", "`cmp_id` is not a whole number"), + ("zero, which a stub reports before it has an id", "0"), + ("past the 12 bits a TC string holds", "4096"), + ("past what the field could hold", "70000"), + ("below zero", "-1"), + ("a string", "\"10\""), + ("a fraction", "10.5"), ]; - for (case, value, reason) in cases { + for (case, value) in cases { let settings = with_cmp_id(value); for (call, refusal) in [ ("loading the settings", load(&settings).err()), @@ -756,8 +744,12 @@ mod tests { .current_context() .to_string(); assert!( - message.contains("[cmp.inmobi]") && message.contains(reason), - "{case}: {call} should say {reason:?}, and said {message:?}" + message.contains("[cmp.inmobi]") && message.contains("`cmp_id`"), + "{case}: {call} should name the table and the setting, and said {message:?}" + ); + assert!( + !message.contains(value), + "{case}: {call} should not quote the value, and said {message:?}" ); } }