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 `