diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index 39345137b..35263c0eb 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -29,6 +29,7 @@ ids = ["trusted_server_secrets"] "#; const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES"; const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES"; +const GAM_ATTRIBUTION_ENV: &str = "TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED"; struct MigratedProject { directory: TempDir, @@ -112,6 +113,49 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() { ); } +#[test] +fn migrated_legacy_config_applies_gam_attribution_environment_override() { + let project = migrated_legacy_project(); + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .args(["config", "push", "--adapter", "axum", "--manifest"]) + .arg(&project.manifest_path) + .arg("--app-config") + .arg(&project.config_path) + .args(["--yes", "--no-diff"]) + .current_dir(project.directory.path()) + .env(GAM_ATTRIBUTION_ENV, "true") + .output() + .expect("should run ts config push"); + + assert!( + output.status.success(), + "valid boolean overlay should push successfully: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let local_store_path = project + .directory + .path() + .join(".edgezero/local-config-trusted_server_config.json"); + let local_store: serde_json::Value = serde_json::from_str( + &fs::read_to_string(local_store_path).expect("should read pushed local config"), + ) + .expect("should parse local config store"); + let envelope_json = local_store + .as_object() + .and_then(|entries| entries.values().next()) + .and_then(serde_json::Value::as_str) + .expect("should contain a blob envelope"); + let envelope: serde_json::Value = + serde_json::from_str(envelope_json).expect("should parse blob envelope"); + + assert_eq!( + envelope["data"]["integrations"]["gpt"]["gam_attribution_enabled"], + serde_json::Value::Bool(true), + "pushed config should contain the GAM attribution environment override" + ); +} + #[test] fn migrated_legacy_config_applies_sanitize_creatives_environment_override() { let project = migrated_legacy_project(); diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index e44b0cbcf..6fc9c8dc5 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -1711,11 +1711,18 @@ mod tests { #[test] fn to_ad_slot_sets_floor_price_and_formats() { - let slot = make_slot("atf", vec!["/"]); + let mut slot = make_slot("atf", vec!["/"]); + slot.targeting + .insert("ts".to_string(), "operator-value".to_string()); let ad_slot = slot.to_ad_slot(); assert_eq!(ad_slot.id, "atf"); assert_eq!(ad_slot.floor_price, Some(0.50)); assert_eq!(ad_slot.formats.len(), 1); + assert_eq!( + ad_slot.targeting.get("ts"), + Some(&serde_json::Value::String("operator-value".to_owned())), + "should preserve operator-provided ts targeting verbatim" + ); } #[test] diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 3bff588fe..10dce6658 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -356,7 +356,11 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso } // Main bundle: core + non-deferred integrations (synchronous). let immediate_ids = integrations.js_module_ids_immediate(); - snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); + let script_attributes = integrations.tsjs_script_tag_attributes(); + snippet.push_str(&tsjs::tsjs_script_tag_with_attributes( + &immediate_ids, + &script_attributes, + )); // Active diagnostics loads synchronously after core so its // GPT listeners precede publisher scripts in the origin head. if let Some(module_tag) = gpt_diagnostics @@ -835,6 +839,76 @@ mod tests { ); } + #[test] + fn integration_head_injector_marks_only_attribution_enabled_gpt_bundle() { + fn process(gpt_config: Option<(bool, bool)>) -> String { + let integrations = if let Some((enabled, gam_attribution_enabled)) = gpt_config { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "gpt", + &json!({ + "enabled": enabled, + "gam_attribution_enabled": gam_attribution_enabled + }), + ) + .expect("should insert GPT config"); + IntegrationRegistry::new(&settings).expect("should build GPT registry") + } else { + IntegrationRegistry::empty_for_tests() + }; + let mut config = create_test_config(); + config.integrations = integrations; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"", true) + .expect("should process HTML"); + + String::from_utf8(output).expect("should produce valid UTF-8") + } + + let attributed = process(Some((true, true))); + let unattributed = process(Some((true, false))); + let disabled_gpt = process(Some((false, true))); + let without_gpt = process(None); + + for html in [&attributed, &unattributed, &disabled_gpt, &without_gpt] { + assert_eq!( + html.matches("id=\"trustedserver-js\"").count(), + 1, + "should emit exactly one publisher bundle tag: {html}" + ); + } + assert!( + attributed.contains("data-ts-gam-attribution=\"true\""), + "should mark only an attribution-enabled GPT publisher bundle" + ); + assert!( + !unattributed.contains("data-ts-gam-attribution"), + "should leave an attribution-disabled GPT publisher bundle unmarked" + ); + assert!( + !disabled_gpt.contains("data-ts-gam-attribution"), + "should let the GPT master switch suppress attribution metadata" + ); + assert!( + !without_gpt.contains("data-ts-gam-attribution"), + "should leave a non-GPT publisher bundle unmarked" + ); + + let head_insert_index = attributed + .find("window.__tsjs_installGptShim") + .expect("should include the GPT head insert"); + let publisher_bundle_index = attributed + .find("id=\"trustedserver-js\"") + .expect("should include the publisher bundle"); + assert!( + head_insert_index < publisher_bundle_index, + "should keep integration head inserts before the publisher bundle" + ); + } + #[test] fn active_gpt_diagnostics_loads_standalone_after_unified_bundle_once() { let html = "Test"; diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 3e8f021fe..84158c27e 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -68,6 +68,10 @@ pub struct GptConfig { #[serde(default = "default_enabled")] pub enabled: bool, + /// Enable page-level `ts=true` delivery attribution in GAM. + #[serde(default)] + pub gam_attribution_enabled: bool, + /// URL for the GPT bootstrap script (default: Google's CDN). #[serde(default = "default_script_url")] #[validate(url)] @@ -487,10 +491,17 @@ impl IntegrationHeadInjector for GptIntegration { /// route changes (see `auction/endpoints.rs`). /// The `POST /auction` endpoint is not involved in scroll or refresh flows. fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + let gam_attribution_flag = if self.config.gam_attribution_enabled { + "window.__tsjs_gam_attribution_enabled=true;" + } else { + "" + }; + let mut scripts = vec![ - "" - .to_string(), + format!( + "" + ), format!("", GPT_BOOTSTRAP_JS), ]; @@ -508,6 +519,14 @@ impl IntegrationHeadInjector for GptIntegration { scripts } + + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + if self.config.gam_attribution_enabled { + vec![("data-ts-gam-attribution", "true")] + } else { + Vec::new() + } + } } /// Inline `window.tsjs.adInit` bootstrap injected at `` so the bids @@ -549,6 +568,7 @@ mod tests { fn test_config() -> GptConfig { GptConfig { enabled: true, + gam_attribution_enabled: false, script_url: default_script_url(), cache_ttl_seconds: 3600, rewrite_script: true, @@ -573,6 +593,29 @@ mod tests { .expect("should build HTTP request") } + #[test] + fn gam_attribution_defaults_to_disabled() { + let config: GptConfig = + serde_json::from_value(serde_json::json!({})).expect("should parse defaults"); + + assert!(!config.gam_attribution_enabled); + } + + #[test] + fn gam_attribution_deserializes_explicit_values() { + let disabled: GptConfig = serde_json::from_value(serde_json::json!({ + "gam_attribution_enabled": false + })) + .expect("should parse explicit false"); + let enabled: GptConfig = serde_json::from_value(serde_json::json!({ + "gam_attribution_enabled": true + })) + .expect("should parse explicit true"); + + assert!(!disabled.gam_attribution_enabled); + assert!(enabled.gam_attribution_enabled); + } + // -- URL detection -- #[test] @@ -1146,6 +1189,38 @@ mod tests { "", "should set the enable flag and call the GPT shim activation function" ); + assert!( + integration.tsjs_script_tag_attributes().is_empty(), + "should not authorize GAM attribution metadata by default" + ); + } + + #[test] + fn gam_attribution_true_adds_both_activation_signals_without_a_new_insert() { + let integration = GptIntegration::new(GptConfig { + gam_attribution_enabled: true, + ..test_config() + }); + let document_state = IntegrationDocumentState::default(); + let context = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&context); + + assert_eq!(inserts.len(), 2, "should not add another head insert"); + assert!( + inserts[0].contains("window.__tsjs_gam_attribution_enabled=true;"), + "should activate the early bootstrap marker" + ); + assert_eq!( + integration.tsjs_script_tag_attributes(), + vec![("data-ts-gam-attribution", "true")], + "should authorize the bundle fallback on the publisher tag" + ); } #[test] @@ -1352,6 +1427,35 @@ mod tests { ); } + #[test] + fn head_inserts_queue_gam_attribution_before_guard_and_ad_requests() { + let targeting_index = GPT_BOOTSTRAP_JS + .find("gpt.setConfig({ targeting: { ts: \"true\" } })") + .expect("should apply the fixed page-level GAM targeting pair"); + let guard_index = GPT_BOOTSTRAP_JS + .find("if (ts.adInit) return;") + .expect("should retain the preinstalled adInit guard"); + let display_index = GPT_BOOTSTRAP_JS + .find("googletag.display(divId);") + .expect("should retain the executable GPT display call"); + let refresh_index = GPT_BOOTSTRAP_JS + .find("googletag.pubads().refresh(slotsNeedingRefresh);") + .expect("should retain the bounded GPT refresh call"); + + assert!( + targeting_index < guard_index, + "should enqueue attribution before the preinstalled adInit guard" + ); + assert!( + targeting_index < display_index, + "should enqueue attribution before the executable display call" + ); + assert!( + targeting_index < refresh_index, + "should enqueue attribution before the executable refresh call" + ); + } + #[test] fn head_injector_integration_id() { let integration = GptIntegration::new(test_config()); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 86b51ffa7..eae59d79c 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -17,6 +17,27 @@ (function () { if (typeof window === "undefined") return; var ts = (window.tsjs = window.tsjs || {}); + var tag; + + if (window.__tsjs_gam_attribution_enabled === true) { + tag = window.googletag = window.googletag || { cmd: [] }; + tag.cmd = tag.cmd || []; + tag.cmd.push(function () { + try { + var gpt = window.googletag; + if (gpt && typeof gpt.setConfig === "function") { + // "ts" is the fixed GAM key, not the local window.tsjs alias. + gpt.setConfig({ targeting: { ts: "true" } }); + } + } catch (error) { + // Attribution must not interrupt the existing bootstrap queue. + ts.log && + ts.log.warn && + ts.log.warn("GAM attribution targeting failed", error); + } + }); + } + if (ts.adInit) return; // Track whether the publisher disabled GPT initial load. Read the effective @@ -38,7 +59,9 @@ return true; } - (window.googletag = window.googletag || { cmd: [] }).cmd.push(function () { + tag = tag || (window.googletag = window.googletag || { cmd: [] }); + tag.cmd = tag.cmd || []; + tag.cmd.push(function () { var gpt = window.googletag; syncInitialLoadDisabled(gpt); if ( diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 16cbac868..280eae847 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -575,6 +575,11 @@ pub trait IntegrationHeadInjector: Send + Sync { fn integration_id(&self) -> &'static str; /// Return HTML snippets to insert at the start of ``. fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; + + /// Return attributes to add to the publisher TSJS bundle tag. + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + Vec::new() + } } /// Registration payload returned by integration builders. @@ -1053,6 +1058,30 @@ impl IntegrationRegistry { inserts } + /// Collect static attributes for the publisher TSJS bundle tag. + #[must_use] + pub fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + let mut attributes: Vec<(&'static str, &'static str)> = Vec::new(); + for injector in &self.inner.head_injectors { + for attribute in injector.tsjs_script_tag_attributes() { + let existing = attributes + .iter() + .find(|(name, _)| *name == attribute.0) + .copied(); + match existing { + None => attributes.push(attribute), + Some((_, kept_value)) if kept_value != attribute.1 => log::warn!( + "Integration `{}` emits conflicting value for publisher tag attribute `{}`; keeping the first", + injector.integration_id(), + attribute.0 + ), + Some(_) => {} + } + } + } + attributes + } + /// Provide a snapshot of registered integrations and their hooks. #[must_use] pub fn registered_integrations(&self) -> Vec { @@ -1321,6 +1350,79 @@ mod tests { use crate::platform::test_support::noop_services; use http::{HeaderValue, StatusCode, header}; + struct DefaultMetadataHeadInjector; + + impl IntegrationHeadInjector for DefaultMetadataHeadInjector { + fn integration_id(&self) -> &'static str { + "default-metadata" + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + Vec::new() + } + } + + struct StaticMetadataHeadInjector; + + impl IntegrationHeadInjector for StaticMetadataHeadInjector { + fn integration_id(&self) -> &'static str { + "static-metadata" + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + Vec::new() + } + + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + vec![ + ("data-ts-gam-attribution", "true"), + ("data-test-order", "second"), + ] + } + } + + struct ConflictingMetadataHeadInjector; + + impl IntegrationHeadInjector for ConflictingMetadataHeadInjector { + fn integration_id(&self) -> &'static str { + "conflicting-metadata" + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + Vec::new() + } + + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + vec![ + ("data-ts-gam-attribution", "false"), + ("data-third-attribute", "third"), + ] + } + } + + #[test] + fn tsjs_script_tag_attributes_preserve_registration_order_and_default_empty() { + let registry = IntegrationRegistry::from_rewriters_with_head_injectors( + Vec::new(), + Vec::new(), + vec![ + Arc::new(DefaultMetadataHeadInjector), + Arc::new(StaticMetadataHeadInjector), + Arc::new(ConflictingMetadataHeadInjector), + ], + ); + + assert_eq!( + registry.tsjs_script_tag_attributes(), + vec![ + ("data-ts-gam-attribution", "true"), + ("data-test-order", "second"), + ("data-third-attribute", "third"), + ], + "should keep the first value for duplicate names and preserve attribute order" + ); + } + // Mock integration proxy for testing struct MockProxy; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4bed98327..d95f82567 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -7754,7 +7754,15 @@ mod tests { } fn streaming_finalize_response(params: OwnedProcessResponseParams, body: EdgeBody) -> EdgeBody { - let settings = Arc::new(create_test_settings()); + streaming_finalize_response_with_settings(params, body, create_test_settings()) + } + + fn streaming_finalize_response_with_settings( + params: OwnedProcessResponseParams, + body: EdgeBody, + settings: Settings, + ) -> EdgeBody { + let settings = Arc::new(settings); let registry = Arc::new( IntegrationRegistry::new(&settings).expect("should create integration registry"), ); @@ -7807,6 +7815,40 @@ mod tests { } } + #[test] + fn streaming_finalize_emits_gam_attribution_head_before_origin_eof() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "gpt", + &serde_json::json!({ + "enabled": true, + "gam_attribution_enabled": true + }), + ) + .expect("should insert GPT config"); + + let body = streaming_finalize_response_with_settings( + html_stream_params("", None), + origin_chunk_then_pending(bytes::Bytes::from_static( + b"

origin remains pending

", + )), + settings, + ); + let html = String::from_utf8(first_lazy_body_chunk(body).to_vec()) + .expect("should emit UTF-8 HTML"); + + assert!( + html.contains("__tsjs_gam_attribution_enabled=true"), + "first rewritten head chunk should carry the primary activation flag: {html}" + ); + assert!( + html.contains("data-ts-gam-attribution=\"true\""), + "first rewritten head chunk should authorize the bundle fallback: {html}" + ); + } + #[test] fn streaming_finalize_emits_compressed_html_before_origin_eof() { // The FCP regression from #849: the lazy body must emit its first @@ -8736,9 +8778,14 @@ mod tests { #[test] fn ad_slots_script_contains_slot_data() { - let slots = vec![make_slot()]; + let mut slot = make_slot(); + slot.targeting + .insert("ts".to_string(), "operator-value".to_string()); + let slots = vec![slot]; let config = make_config(); let script = build_ad_slots_script(&slots, &config, "/"); + let slot_json = crate::publisher::build_slot_json(&slots[0], &config, "example") + .expect("should build slot JSON"); assert!( script.contains("window.tsjs=window.tsjs||{}"), "should initialise tsjs namespace" @@ -8753,6 +8800,10 @@ mod tests { !script.contains("__ts_request_id"), "must NOT contain request_id" ); + assert_eq!( + slot_json["targeting"]["ts"], "operator-value", + "should forward operator-provided ts targeting verbatim" + ); } #[test] diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 133e6d011..20e7d3ded 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -11,9 +11,38 @@ pub fn tsjs_script_src(module_ids: &[&str]) -> String { /// `", - tsjs_script_src(module_ids) + "", + tsjs_script_src(module_ids), ) } @@ -170,19 +199,79 @@ mod tests { ); } + #[test] + fn publisher_tsjs_script_tag_renders_static_attributes() { + let module_ids = ["gpt"]; + let src = tsjs_script_src(&module_ids); + + assert_eq!( + tsjs_script_tag_with_attributes(&module_ids, &[("data-ts-gam-attribution", "true")]), + format!( + "" + ), + "should render trusted static attributes on the publisher bundle tag" + ); + assert_eq!( + tsjs_script_tag(&module_ids), + format!(""), + "should keep the generic tag byte-for-byte unmarked" + ); + } + + #[test] + #[should_panic( + expected = "attribute name should contain only lowercase ASCII letters, digits, and hyphens" + )] + fn publisher_tsjs_script_tag_rejects_invalid_attribute_name() { + let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-bad_name", "true")]); + } + + #[test] + #[should_panic( + expected = "attribute name should contain only lowercase ASCII letters, digits, and hyphens" + )] + fn publisher_tsjs_script_tag_rejects_empty_attribute_name() { + let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("", "true")]); + } + + #[test] + #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] + fn publisher_tsjs_script_tag_rejects_double_quote_in_attribute_value() { + let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "bad\"value")]); + } + + #[test] + #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] + fn publisher_tsjs_script_tag_rejects_ampersand_in_attribute_value() { + let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "bad&value")]); + } + + #[test] + #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] + fn publisher_tsjs_script_tag_rejects_less_than_in_attribute_value() { + let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "badvalue")]); + } + #[test] fn tsjs_unified_helpers_use_all_module_ids() { let ids = all_module_ids(); + let src = tsjs_unified_script_src(); assert_eq!( - tsjs_unified_script_src(), + src, tsjs_script_src(&ids), "should hash all module IDs for the unified script source" ); assert_eq!( tsjs_unified_script_tag(), - tsjs_script_tag(&ids), - "should wrap the all-module unified script source" + format!(""), + "should keep the all-module generic tag byte-for-byte unmarked" ); } diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/script-injection.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/script-injection.spec.ts index 3a38aa746..78059596f 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/script-injection.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/script-injection.spec.ts @@ -9,6 +9,7 @@ test.describe("Script injection", () => { const src = await scriptTag.getAttribute("src"); expect(src).toContain("/static/tsjs="); + await expect(scriptTag).not.toHaveAttribute("data-ts-gam-attribution"); }); test("no unexpected console errors on page load", async ({ page }) => { diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index 17d7c2713..d8e35d179 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -86,6 +86,7 @@ rewrite_sdk = true [integrations.gpt] enabled = false +gam_attribution_enabled = false script_url = "https://ads.example.com/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index f0df35974..46acd1b0c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -287,6 +287,8 @@ type GptWindow = Window & { __tsjs_slim_prebid_url?: string; }; +const executingPublisherScript = typeof document === 'undefined' ? null : document.currentScript; + // ------------------------------------------------------------------ // Shim implementation // ------------------------------------------------------------------ @@ -307,6 +309,25 @@ function ensureGoogleTagStub(win: GptWindow): Partial { return tag; } +function installTrustedServerPageTargeting(): void { + if (executingPublisherScript?.getAttribute('data-ts-gam-attribution') !== 'true') { + return; + } + + const win = window as GptWindow; + const tag = ensureGoogleTagStub(win); + tag.cmd!.push(() => { + try { + const gpt = win.googletag; + if (typeof gpt?.setConfig === 'function') { + gpt.setConfig({ targeting: { ts: 'true' } }); + } + } catch (error) { + log.warn('[tsjs-gpt] GAM attribution targeting failed', error); + } + }); +} + /** * Wrap a queued GPT callback to add instrumentation and future hook points. * @@ -1892,6 +1913,7 @@ if (typeof window !== 'undefined') { installGptShim(); } + installTrustedServerPageTargeting(); installTsAdInit(); installSpaAuctionHook(); installSlimPrebidLoader(); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 179a810d5..7e7c6d10a 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -1874,7 +1874,7 @@ describe('installTsAdInit', () => { setTargeting: vi.fn().mockReturnThis(), clearTargeting, getSlotElementId: vi.fn().mockReturnValue('div-old-route'), - getTargeting: vi.fn().mockReturnValue([]), + getTargeting: vi.fn((key: string) => (key === 'ts' ? ['publisher-value'] : [])), }; const mockPubads = { enableSingleRequest: vi.fn(), @@ -1908,6 +1908,7 @@ describe('installTsAdInit', () => { expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(clearTargeting).toHaveBeenCalledWith('pos'); + expect(clearTargeting).not.toHaveBeenCalledWith('ts'); expect(mockPubads.refresh).not.toHaveBeenCalled(); expect((window as TestWindow).tsjs!.divToSlotId).toEqual({}); expect((window as TestWindow).tsjs!.prevSlotTargetingKeys).toEqual({}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index d3e1d7099..e7b513fdb 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -33,6 +33,7 @@ interface MockGoogleTag { pubads: () => unknown; enableServices: () => void; display: (divId: string) => void; + setConfig?: (config: Record) => void; } // `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from @@ -40,8 +41,25 @@ interface MockGoogleTag { type TestWindow = Omit & { googletag?: MockGoogleTag; tsjs?: Partial; + __tsjs_gam_attribution_enabled?: boolean; }; +function makeGoogleTag(overrides: Partial = {}): MockGoogleTag { + const pubads = { + getSlots: vi.fn(() => []), + refresh: vi.fn(), + }; + + return { + cmd: [], + defineSlot: vi.fn(), + pubads: vi.fn(() => pubads), + enableServices: vi.fn(), + display: vi.fn(), + ...overrides, + }; +} + function runBootstrap(): void { // Evaluate in the jsdom global scope, exactly as an inline '); + clonedDocument.close(); + executingScript = clonedDocument.querySelector('script'); + const queue: Array<() => void> = []; + const setConfig = vi.fn(); + win.googletag = makeGoogleTag({ cmd: queue, setConfig }); + + await importFreshGptBundle(); + queue[0](); + + expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }); + }); + + it.each(['missing', 'throwing'])( + 'keeps module installation working with %s setConfig', + async (setConfigMode) => { + const queue: Array<() => void> = []; + const setConfig = + setConfigMode === 'throwing' + ? vi.fn(() => { + throw new Error('publisher setConfig failed'); + }) + : undefined; + win.googletag = makeGoogleTag({ cmd: queue, setConfig }); + win.__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid.js'; + executingScript = attributedScript(); + + await importFreshGptBundle(); + + expect(() => [...queue].forEach((command) => command())).not.toThrow(); + expect(typeof win.tsjs?.adInit).toBe('function'); + expect(typeof win.tsjs?.scheduleInitialAdInit).toBe('function'); + expect(win.tsjs?.spaHookInstalled).toBe(true); + expect(addEventListenerSpy).toHaveBeenCalledWith('popstate', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('load', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); + if (setConfig) { + expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }); + } + } + ); + + it('preserves GPT-enabled shim behavior without queuing attribution when unmarked', async () => { + const queue: Array<() => void> = []; + const setConfig = vi.fn(); + const tag = makeGoogleTag({ cmd: queue, setConfig }); + win.googletag = tag; + win.__tsjs_gpt_enabled = true; + executingScript = document.createElement('script'); + + await importFreshGptBundle(); + [...queue].forEach((command) => command()); + const guard = await importGuardModule(); + + expect(guard.isGuardInstalled()).toBe(true); + expect(win.googletag).toBe(tag); + expect(win.googletag!.cmd).toBe(queue); + expect(setConfig).not.toHaveBeenCalled(); + expect(typeof win.tsjs?.adInit).toBe('function'); + }); +}); + describe('GPT debug ADM iframe hardening', () => { it('sandbox token list omits allow-same-origin', async () => { const mod = await import('../../../src/integrations/gpt/index'); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9f9a3f977..8ead01aa8 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -414,9 +414,11 @@ describe('prebid/installPrebidNpm', () => { delete testWindow.__tsjs_prebid_diagnostics; delete testWindow.tsjs; delete mockPbjs['__tsApsBidResponseListenerInstalled']; + delete mockPbjs.bidderSettings; }); afterEach(() => { + delete mockPbjs.bidderSettings; vi.restoreAllMocks(); }); @@ -1130,6 +1132,35 @@ describe('prebid/installPrebidNpm', () => { }); describe('requestBids shim', () => { + it('preserves publisher ts adserverTargeting while adding trustedServer settings', () => { + const publisherTargeting = [{ key: 'ts', val: () => 'publisher-value' }]; + mockPbjs.bidderSettings = { + exampleBidder: { adserverTargeting: publisherTargeting }, + }; + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'exampleBidder', params: {} }] }], + } as unknown as RequestBidsArg); + + const bidderSettings = mockPbjs.bidderSettings as { + exampleBidder: { adserverTargeting: typeof publisherTargeting }; + trustedServer: { + allowAlternateBidderCodes: boolean; + allowedAlternateBidderCodes: string[]; + }; + }; + expect(bidderSettings.exampleBidder.adserverTargeting).toBe(publisherTargeting); + expect(bidderSettings.exampleBidder.adserverTargeting[0].key).toBe('ts'); + expect(bidderSettings.exampleBidder.adserverTargeting[0].val()).toBe('publisher-value'); + expect(bidderSettings.trustedServer).toEqual( + expect.objectContaining({ + allowAlternateBidderCodes: true, + allowedAlternateBidderCodes: ['*'], + }) + ); + }); + it('injects trustedServer bidder into every ad unit', () => { const pbjs = installPrebidNpm(); @@ -1850,25 +1881,33 @@ describe('prebid/installRefreshHandler', () => { it('auctions refreshed TS initial slots and clears stale TS targeting before refresh', () => { const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); + const slotTargeting = new Map([ + ['ts_initial', ['1']], + ['zone', ['homepage']], + ]); + const clearTargeting = vi.fn((key: string) => { + slotTargeting.delete(key); + }); + const setTargeting = vi.fn((key: string, value: string | string[]) => { + slotTargeting.set(key, Array.isArray(value) ? value : [value]); + }); const gptSlot = { getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => { - if (key === 'ts_initial') return ['1']; - if (key === 'zone') return ['homepage']; - return []; - }), + getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), getSizes: vi.fn(() => [ { getWidth: () => 970, getHeight: () => 250 }, { getWidth: () => 728, getHeight: () => 90 }, ]), clearTargeting, + setTargeting, }; const pubads = { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - const setTargetingForGPTAsync = vi.fn(); + const setTargetingForGPTAsync = vi.fn(() => { + gptSlot.setTargeting('ts', 'prebid-value'); + }); mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, @@ -1918,12 +1957,17 @@ describe('prebid/installRefreshHandler', () => { expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(clearTargeting).not.toHaveBeenCalledWith('ts'); expect(originalRefresh).not.toHaveBeenCalled(); const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler; bidsBackHandler(); expect(setTargetingForGPTAsync).toHaveBeenCalled(); + expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( + originalRefresh.mock.invocationCallOrder[0] + ); + expect(slotTargeting.get('ts')).toEqual(['prebid-value']); expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); }); diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md index de0df03c4..b55f65f7e 100644 --- a/docs/guide/integrations/gpt.md +++ b/docs/guide/integrations/gpt.md @@ -53,6 +53,7 @@ Add GPT configuration to `trusted-server.toml`: ```toml [integrations.gpt] enabled = true +gam_attribution_enabled = false script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true @@ -60,12 +61,18 @@ rewrite_script = true ### Configuration Options -| Field | Type | Required | Default | Description | -| ------------------- | ------- | -------- | ------------------------------------------------------ | ------------------------------------------ | -| `enabled` | boolean | No | `true` | Enable/disable the integration | -| `script_url` | string | No | `https://securepubads.g.doubleclick.net/tag/js/gpt.js` | URL for the GPT bootstrap script | -| `cache_ttl_seconds` | integer | No | `3600` | Cache TTL for proxied scripts (60--86400s) | -| `rewrite_script` | boolean | No | `true` | Whether to rewrite GPT script URLs in HTML | +| Field | Type | Required | Default | Description | +| ------------------------- | ------- | -------- | ------------------------------------------------------ | ----------------------------------------------------------------- | +| `enabled` | boolean | No | `true` | Enable/disable the integration | +| `gam_attribution_enabled` | boolean | No | `false` | Add fixed page-level `ts=true` targeting for GAM cohort reporting | +| `script_url` | string | No | `https://securepubads.g.doubleclick.net/tag/js/gpt.js` | URL for the GPT bootstrap script | +| `cache_ttl_seconds` | integer | No | `3600` | Cache TTL for proxied scripts (60--86400s) | +| `rewrite_script` | boolean | No | `true` | Whether to rewrite GPT script URLs in HTML | + +The environment override +`TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED` works only when +`gam_attribution_enabled` is already present under `[integrations.gpt]` in the +TOML file. The environment overlay cannot create a missing configuration leaf. ## Endpoints @@ -109,6 +116,65 @@ Takes over `googletag.cmd` so every queued callback is wrapped before GPT execut - Consent gating of ad requests - Ad-unit path rewriting for A/B testing +### GAM Treatment Attribution + +Setting `gam_attribution_enabled = true` adds the fixed page-level GPT targeting +value `ts=true`. It is applied before publisher GPT initialization and remains +for the browser document's lifetime, so initial, lazy, refresh, publisher-owned, +and SPA-route requests inherit it unless another targeting consumer clears or +overrides the key. The attribution switch is independently controlled and +defaults to `false`, but the GPT integration's `enabled` master switch must also +be `true`. + +This key is distinct from the existing slot-level `ts_initial=1` value. +`ts_initial` retains its current cleanup lifecycle; Trusted Server does not +clear the page-level `ts` value during Prebid refresh or SPA cleanup. + +For an eligible publisher document whose activation script was not cloned, +`ts=true` means Trusted Server emitted the rewritten document head before the +GPT request. It does not prove that the response body completed, that a Trusted +Server bid won, or that an impression was caused by treatment. A publisher can +copy the activation script with `srcdoc` or `document.write`; treat any marker +on an unrewritten nested document as contamination, not attribution proof. + +Before enabling attribution in a cohort: + +1. Complete privacy and CSP review, create the reportable predefined `true` + value in the target GAM network, and verify the chosen GAM reporting surface + and billing approval. +2. Audit the short `ts` key across publisher GPT code, effective Prebid + `bidderSettings[*].adserverTargeting` output (including + `setTargetingForGPTAsync`), the effective creative-opportunity targeting map, + and every GAM consumer that can affect eligibility, pricing, protection, or + routing. Trusted Server accepts and forwards operator targeting verbatim; it + does not reserve, filter, or intercept a slot-level `ts` key at runtime. +3. With treatment routing stopped, deploy attribution enabled and validate + initial, lazy, refresh, publisher-owned, and SPA requests. Confirm every + excluded path reports zero marked requests, then save a short paired-report + dry run that satisfies the invariants below before starting the cohort. + +For reporting, save one exact eligible universe: GAM network, inventory units, +routes, formats, time zone, date window, metrics, and all exclusions. Report A +is the nonduplicated total for that universe. Report B uses identical filters +and metrics plus exactly `ts=true`. If Enhanced Key-Value reporting is +unavailable, unapproved, or incompatible, use an exactly filtered legacy +key-value report and never sum its repeated key-value rows. Derive control as +`A - B`, and require `0 <= B <= A` for every metric. A violation invalidates the +whole report pair; never clamp a negative result. Use the same reporting-latency +and invalid-traffic maturation window for both reports. + +GAM results are descriptive delivery attribution, not a causal treatment +effect. Aggregate monitoring and synthetic/manual samples can detect obvious +failures but cannot prove marker completeness on every production request +without request-correlated telemetry. + +For a normal rollback, first stop and verify new treatment assignment at the +router, record a clean reporting boundary, and let already-open documents drain. +Exclude the drain interval, then set `gam_attribution_enabled = false` after +marked traffic reaches zero for the agreed interval. An emergency kill may flip +the setting immediately, but the affected interval and subsequent drain must be +treated as invalid for experiment reporting. + ## Use Cases ### First-Party Ad Delivery diff --git a/docs/superpowers/plans/2026-07-15-gam-ts-cohort-attribution.md b/docs/superpowers/plans/2026-07-15-gam-ts-cohort-attribution.md new file mode 100644 index 000000000..eaf07ca51 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-gam-ts-cohort-attribution.md @@ -0,0 +1,1078 @@ +# GAM Page-Delivery Attribution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a disabled-by-default `gam_attribution_enabled` GPT option that marks every eligible request from a Trusted Server head-emitted publisher document with the fixed page-level GAM value `ts=true`. + +**Architecture:** One parsed `GptConfig` instance controls both delivery paths. The raw head bootstrap is the primary, earliest queue insertion; integration-owned publisher-tag metadata adds `data-ts-gam-attribution="true"` to the synchronous bundle for a `document.currentScript`-gated fallback. Existing slot targeting, Prebid refresh cleanup, creative-opportunity forwarding, and Fastly streaming behavior remain unchanged. + +**Tech Stack:** Rust 1.95, Serde, `validator`, `lol_html`, TypeScript, Vitest/jsdom, Playwright, Google Publisher Tag + +--- + +**Issue:** [#1027](https://github.com/IABTechLab/trusted-server/issues/1027) + +**Design:** `docs/superpowers/specs/2026-07-15-gam-ts-cohort-attribution-design.md` + +## Fixed contracts + +| Concern | Contract | +| ------------------ | ---------------------------------------------------------------------------------------------------- | +| Marker | Exactly page-level `ts=true`; no configurable name/value, alias, or dual write | +| Default | `[integrations.gpt] gam_attribution_enabled = false` | +| Kill switch | Disables only attribution; GPT proxying, shim, `adInit`, and `ts_initial` remain active | +| Primary path | Raw GPT bootstrap queues targeting before `if (ts.adInit) return;` | +| Fallback | Existing synchronous publisher bundle, authorized only by its own `document.currentScript` attribute | +| Publisher tag | One tag; `data-ts-gam-attribution="true"` only for enabled GPT attribution | +| Streaming meaning | Rewritten head emitted before the request, not complete response success; no new buffering | +| Slot targeting | `ts_initial=1` lifecycle unchanged; page-level `ts` is never added to cleanup arrays | +| Operator targeting | Forwarded verbatim; characterize collisions but add no validator, filter, or interception | +| Analysis | Descriptive GAM delivery attribution, not a causal treatment effect | + +Run every command block from the repository root unless that block begins with +an explicit `cd`. Treat separate command blocks as separate shell sessions. + +## File map + +| File | Responsibility | +| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/integrations/gpt.rs` | Parse the option, emit the inline activation flag, and expose publisher-tag metadata | +| `crates/trusted-server-core/src/integrations/registry.rs` | Define the default-empty tag-attribute hook and aggregate enabled integration metadata | +| `crates/trusted-server-core/src/tsjs.rs` | Render an attributed publisher bundle tag without changing generic/creative tag output | +| `crates/trusted-server-core/src/html_processor.rs` | Pass registry-owned attributes to the single synchronous publisher bundle tag | +| `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` | Queue the primary `setConfig({ targeting: { ts: "true" } })` callback | +| `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` | Queue the `document.currentScript`-authorized fallback | +| `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts` | Execute the raw bootstrap and prove ordering/failure isolation | +| `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` | Prove exact executing-tag activation and fail-closed cases | +| `crates/trusted-server-core/src/creative_opportunities.rs` | Characterize verbatim operator `ts` targeting; production code remains unchanged | +| `crates/trusted-server-core/src/publisher.rs` | Characterize wire forwarding and marked-head-before-EOF streaming | +| `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` | Freeze GPT slot cleanup behavior | +| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Characterize Prebid-produced slot-level collisions and cleanup behavior | +| `crates/trusted-server-cli/tests/config_env_overlay.rs` | Prove the typed CLI environment override updates an existing TOML leaf | +| `trusted-server.example.toml` | Publish the disabled default | +| `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml` | Keep the browser fixture explicitly default-off | +| `crates/trusted-server-integration-tests/browser/tests/shared/script-injection.spec.ts` | Smoke-test absence of the activation attribute in the default-off deployment | +| `docs/guide/integrations/gpt.md` | Document configuration, semantics, audit, reporting, and rollback prerequisites | + +## Task 1: Add the GPT attribution option and integration-owned metadata + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs:64-94` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs:467-510` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs:549-559` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs:572-579` +- Test: `crates/trusted-server-core/src/integrations/gpt.rs` + +- [ ] **Step 1: Write failing configuration and head-injector tests.** + + Add tests that deserialize omitted, explicit-false, and explicit-true values, + then exercise both activation outputs. Use behavior-oriented assertions like: + + ```rust + #[test] + fn gam_attribution_defaults_to_disabled() { + let config: GptConfig = + serde_json::from_value(serde_json::json!({})).expect("should parse defaults"); + assert!(!config.gam_attribution_enabled); + } + + #[test] + fn gam_attribution_true_adds_both_activation_signals_without_a_new_insert() { + let integration = GptIntegration::new(GptConfig { + gam_attribution_enabled: true, + ..test_config() + }); + let document_state = IntegrationDocumentState::default(); + let context = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + document_state: &document_state, + }; + let inserts = integration.head_inserts(&context); + + assert_eq!(inserts.len(), 2); + assert!(inserts[0].contains("window.__tsjs_gam_attribution_enabled=true;")); + assert_eq!( + integration.tsjs_script_tag_attributes(), + vec![("data-ts-gam-attribution", "true")] + ); + } + ``` + + Retain the current exact-string assertion for the false first insert. Add + `gam_attribution_enabled: false` to `test_config()` and any other full + `GptConfig` literals. + +- [ ] **Step 2: Run the focused tests and confirm RED.** + + Run: + + ```bash + cargo test-fastly gam_attribution + ``` + + Expected: compilation fails because `GptConfig::gam_attribution_enabled` and + `IntegrationHeadInjector::tsjs_script_tag_attributes` do not exist. + +- [ ] **Step 3: Add the default-empty trait hook and parsed field.** + + Add an object-safe default method beside `head_inserts`: + + ```rust + pub trait IntegrationHeadInjector: Send + Sync { + fn integration_id(&self) -> &'static str; + fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; + + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + Vec::new() + } + } + ``` + + Add the flat field to `GptConfig`: + + ```rust + /// Enable page-level `ts=true` delivery attribution in GAM. + #[serde(default)] + pub gam_attribution_enabled: bool, + ``` + + Do not add a configurable key or value. + +- [ ] **Step 4: Emit the true-only inline flag without changing false bytes.** + + Build the first insert with an empty-or-fixed fragment: + + ```rust + let gam_attribution_flag = self + .config + .gam_attribution_enabled + .then_some("window.__tsjs_gam_attribution_enabled=true;") + .unwrap_or_default(); + + let mut scripts = vec![ + format!( + "" + ), + format!("", GPT_BOOTSTRAP_JS), + ]; + ``` + + Verify the false string remains exactly: + + ```text + + ``` + +- [ ] **Step 5: Override the metadata hook from the same `GptConfig`.** + + ```rust + fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + if self.config.gam_attribution_enabled { + vec![("data-ts-gam-attribution", "true")] + } else { + Vec::new() + } + } + ``` + + Do not store this state in `HtmlProcessorConfig` or + `IntegrationDocumentState`. + +- [ ] **Step 6: Run focused and neighboring GPT tests.** + + ```bash + cargo test-fastly gam_attribution + cargo test-fastly head_injector + ``` + + Expected: PASS; false preserves two current inserts, true adds the flag and + metadata while still emitting two inserts when `slim_prebid_url` is absent. + +- [ ] **Step 7: Commit.** + + ```bash + git add crates/trusted-server-core/src/integrations/gpt.rs crates/trusted-server-core/src/integrations/registry.rs + git commit -m "Add GPT GAM attribution option" + ``` + +## Task 2: Put activation metadata on only the publisher bundle tag + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/registry.rs:1043-1054` +- Modify: `crates/trusted-server-core/src/tsjs.rs:11-39` +- Modify: `crates/trusted-server-core/src/html_processor.rs:324-360` +- Test: `crates/trusted-server-core/src/integrations/registry.rs` +- Test: `crates/trusted-server-core/src/tsjs.rs:161-187` +- Test: `crates/trusted-server-core/src/html_processor.rs:768-820` +- Test: `crates/trusted-server-core/src/html_processor.rs:1637-1671` + +- [ ] **Step 1: Write failing registry and tag-rendering tests.** + + Add a test head injector whose metadata method returns the attribution pair. + Assert registry aggregation is deterministic and preserves the default-empty + behavior of injectors that implement only `head_inserts`. + + Add exact tag tests: + + ```rust + #[test] + fn publisher_script_tag_renders_static_attributes() { + let ids = ["gpt"]; + let src = tsjs_script_src(&ids); + + assert_eq!( + tsjs_script_tag_with_attributes( + &ids, + &[("data-ts-gam-attribution", "true")] + ), + format!( + "" + ) + ); + assert_eq!( + tsjs_script_tag(&ids), + format!("") + ); + } + ``` + + The final string must contain no formatting whitespace introduced only by the + multiline example. + +- [ ] **Step 2: Write a failing HTML matrix test.** + + Process `` with: + 1. a real enabled GPT registry with attribution true; + 2. enabled GPT with attribution false; and + 3. no GPT integration. + + Assert exactly one `#trustedserver-js` tag in every case, the attribute only + in case 1, and integration head inserts remain before the external bundle. + Also retain the generic `tsjs_unified_script_tag()` exact-output test so + creative/all-modules callers stay unmarked. + +- [ ] **Step 3: Run the tests and confirm RED.** + + ```bash + cargo test-fastly tsjs_script_tag + cargo test-fastly integration_head_injector + ``` + + Expected: FAIL because the registry aggregator and attributed publisher + helper are not implemented. + +- [ ] **Step 4: Aggregate integration-owned static attributes.** + + Add beside `IntegrationRegistry::head_inserts`: + + ```rust + #[must_use] + pub fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { + self.inner + .head_injectors + .iter() + .flat_map(|injector| injector.tsjs_script_tag_attributes()) + .collect() + } + ``` + + Keep the hook default-empty so existing integration injectors and test doubles + compile without changes. + +- [ ] **Step 5: Add the publisher-only tag helper.** + + Render only trusted, compile-time static attribute pairs: + + ```rust + #[must_use] + pub fn tsjs_script_tag_with_attributes( + module_ids: &[&str], + attributes: &[(&'static str, &'static str)], + ) -> String { + let attributes = attributes + .iter() + .map(|(name, value)| format!(" {name}=\"{value}\"")) + .collect::(); + format!( + "", + tsjs_script_src(module_ids) + ) + } + ``` + + Have `tsjs_script_tag(module_ids)` retain its exact output, either directly or + by delegating with an empty slice. Do not change + `tsjs_unified_script_tag()` or either creative call site. + +- [ ] **Step 6: Wire only the publisher HTML path.** + + Replace the single `html_processor.rs` call with: + + ```rust + let immediate_ids = integrations.js_module_ids_immediate(); + let script_attributes = integrations.tsjs_script_tag_attributes(); + snippet.push_str(&tsjs::tsjs_script_tag_with_attributes( + &immediate_ids, + &script_attributes, + )); + ``` + + Preserve source order: ad slots, integration head inserts, diagnostics + bootstrap, one synchronous bundle, diagnostics module, deferred bundles. + +- [ ] **Step 7: Run focused tests.** + + ```bash + cargo test-fastly tsjs_script_tag + cargo test-fastly integration_head_injector + cargo test-fastly golden_script_tag + ``` + + Expected: PASS; false/non-GPT/generic output is unmarked and true output has + one attributed publisher tag. + +- [ ] **Step 8: Commit.** + + ```bash + git add crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-core/src/tsjs.rs crates/trusted-server-core/src/html_processor.rs + git commit -m "Authorize GAM attribution bundle" + ``` + +## Task 3: Queue the primary marker before the bootstrap guard + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js:17-45` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts:8-220` +- Test: `crates/trusted-server-core/src/integrations/gpt.rs:1131-1454` + +- [ ] **Step 1: Extend the raw-source test harness.** + + Add the optional page flag and `setConfig` surface: + + ```typescript + interface MockGoogleTag { + cmd: MockCommandQueue + setConfig?: (config: Record) => void + // retain the existing members + } + + type TestWindow = Omit & { + googletag?: MockGoogleTag + tsjs?: Partial + __tsjs_gam_attribution_enabled?: boolean + } + + function makeGoogleTag( + overrides: Partial = {} + ): MockGoogleTag { + return { + cmd: [], + defineSlot: vi.fn(), + pubads: vi.fn(() => ({})), + enableServices: vi.fn(), + display: vi.fn(), + ...overrides, + } + } + ``` + + Delete the flag in both `beforeEach` and `afterEach`. + +- [ ] **Step 2: Write failing behavioral tests.** + + Cover all of these independently: + - default/false plus a preinstalled `ts.adInit` returns without creating + `window.googletag`; + - true queues the exact string-valued targeting callback before a publisher + callback appended after `runBootstrap()`; + - true plus preinstalled `ts.adInit` still queues and applies targeting but + does not replace `adInit` or install the fallback scheduler; + - missing `setConfig` is a no-op and the initial-load detector and `adInit` + still install; + - throwing `setConfig` is caught inside the marker callback, and a later + publisher callback still executes; + - the wrapped `disableInitialLoad` path still records + `ts.gptInitialLoadDisabled`. + + Use a real array queue, append a publisher spy after bootstrap execution, and + drain a snapshot in order: + + ```typescript + const queue: Array<() => void> = [] + const setConfig = vi.fn() + ;(window as TestWindow).googletag = makeGoogleTag({ cmd: queue, setConfig }) + ;(window as TestWindow).__tsjs_gam_attribution_enabled = true + + runBootstrap() + const publisherCommand = vi.fn() + queue.push(publisherCommand) + ;[...queue].forEach((command) => command()) + + expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }) + expect(setConfig.mock.invocationCallOrder[0]).toBeLessThan( + publisherCommand.mock.invocationCallOrder[0] + ) + ``` + +- [ ] **Step 3: Run the raw bootstrap tests and confirm RED.** + + ```bash + cd crates/trusted-server-js/lib + npx vitest run test/integrations/gpt/gpt_bootstrap.test.ts + ``` + + Expected: targeting assertions fail because the raw bootstrap returns before + any marker enqueue. + +- [ ] **Step 4: Implement one flag-gated queue initialization before the guard.** + + Preserve the local `ts` namespace and reuse `tag` in the existing detector: + + ```javascript + var ts = (window.tsjs = window.tsjs || {}) + var tag + + if (window.__tsjs_gam_attribution_enabled === true) { + tag = window.googletag = window.googletag || { cmd: [] } + tag.cmd = tag.cmd || [] + tag.cmd.push(function () { + try { + var gpt = window.googletag + if (gpt && typeof gpt.setConfig === 'function') { + // "ts" is the fixed GAM key, not the local window.tsjs alias. + gpt.setConfig({ targeting: { ts: 'true' } }) + } + } catch (_) { + // Attribution must not interrupt the existing bootstrap queue. + } + }) + } + + if (ts.adInit) return + + tag = tag || (window.googletag = window.googletag || { cmd: [] }) + tag.cmd = tag.cmd || [] + tag.cmd.push(function () { + // existing initial-load detector body, unchanged + }) + ``` + + Do not add a global deduplication state machine, network call, beacon, cookie + read, slot-level key, or third head insert. + +- [ ] **Step 5: Add/retain Rust source-order assertions.** + + In `gpt.rs`, assert the embedded bootstrap's attribution enqueue occurs before + `if (ts.adInit) return;` and before the executable + `googletag.display(` and `googletag.pubads().refresh(` tokens. Do not compare + against comment-only `display()`/`refresh()` text. Retain `ts_initial` + assertions and the two-insert count. + +- [ ] **Step 6: Run focused JS and Rust tests.** + + ```bash + cd crates/trusted-server-js/lib + npx vitest run test/integrations/gpt/gpt_bootstrap.test.ts + cd ../../.. + cargo test-fastly head_inserts + ``` + + Expected: PASS; default behavior is unchanged and every failure mode is + isolated from the existing bootstrap. + +- [ ] **Step 7: Commit.** + + ```bash + git add crates/trusted-server-core/src/integrations/gpt_bootstrap.js crates/trusted-server-core/src/integrations/gpt.rs crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts + git commit -m "Queue page-level GAM attribution" + ``` + +## Task 4: Add the exact-executing-tag bundle fallback + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:259-307` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1878-1898` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts:350-421` + +- [ ] **Step 1: Add a test helper that controls `document.currentScript`.** + + In the runtime-gating suite, install a configurable getter before a fresh + dynamic import and restore it afterward: + + ```typescript + let executingScript: HTMLScriptElement | null + + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => executingScript, + }) + ``` + + Use actual `