diff --git a/crates/common/src/integrations/gpt.rs b/crates/common/src/integrations/gpt.rs
new file mode 100644
index 00000000..5946ec26
--- /dev/null
+++ b/crates/common/src/integrations/gpt.rs
@@ -0,0 +1,921 @@
+//! Google Publisher Tags (GPT) integration for first-party ad serving.
+//!
+//! This module provides transparent proxying for Google's entire GPT script
+//! chain, enabling first-party ad tag delivery while maintaining privacy
+//! controls. GPT loads scripts in a cascade:
+//!
+//! 1. `gpt.js` – the thin bootstrap loader
+//! 2. `pubads_impl.js` – the main GPT implementation (~640 KB)
+//! 3. `pubads_impl_*.js` – lazy-loaded sub-modules (page-level ads, side rails, …)
+//! 4. Auxiliary scripts – viewability, monitoring, error reporting
+//!
+//! All of these are served from `securepubads.g.doubleclick.net`. The
+//! integration proxies these scripts
+//! through the publisher's domain while a client-side shim intercepts
+//! dynamic script insertions and rewrites their URLs to the first-party
+//! proxy so that every subsequent fetch in the cascade routes back through
+//! the trusted server.
+//!
+//! ## How It Works
+//!
+//! 1. **HTML rewriting** – The [`IntegrationAttributeRewriter`] swaps `src`/`href`
+//! attributes pointing at Google's GPT script with a first-party URL
+//! (`/integrations/gpt/script`).
+//! 2. **Script proxy** – [`IntegrationProxy`] endpoints serve `gpt.js`
+//! (`/integrations/gpt/script`) and all secondary scripts
+//! (`/integrations/gpt/pagead/*`, `/integrations/gpt/tag/*`) through the
+//! publisher's domain. Script bodies are served **verbatim** — no
+//! server-side domain rewriting is performed.
+//! 3. **Client-side shim** – A TypeScript module (built into the unified TSJS
+//! bundle) installs a script guard that intercepts dynamically inserted GPT
+//! `".to_string()]
+ }
+}
+
+// Default value functions
+
+fn default_enabled() -> bool {
+ true
+}
+
+fn default_script_url() -> String {
+ "https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string()
+}
+
+fn default_cache_ttl() -> u32 {
+ 3600
+}
+
+fn default_rewrite_script() -> bool {
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::integrations::IntegrationDocumentState;
+ use crate::test_support::tests::create_test_settings;
+
+ fn test_config() -> GptConfig {
+ GptConfig {
+ enabled: true,
+ script_url: default_script_url(),
+ cache_ttl_seconds: 3600,
+ rewrite_script: true,
+ }
+ }
+
+ fn test_context() -> IntegrationAttributeContext<'static> {
+ IntegrationAttributeContext {
+ attribute_name: "src",
+ request_host: "edge.example.com",
+ request_scheme: "https",
+ origin_host: "origin.example.com",
+ }
+ }
+
+ // -- URL detection --
+
+ #[test]
+ fn gpt_script_url_detection() {
+ assert!(
+ GptIntegration::is_gpt_script_url(
+ "https://securepubads.g.doubleclick.net/tag/js/gpt.js"
+ ),
+ "should match the standard GPT CDN URL"
+ );
+
+ assert!(
+ GptIntegration::is_gpt_script_url("//securepubads.g.doubleclick.net/tag/js/gpt.js"),
+ "should match protocol-relative GPT CDN URLs"
+ );
+
+ assert!(
+ GptIntegration::is_gpt_script_url(
+ "https://SECUREPUBADS.G.DOUBLECLICK.NET/tag/js/gpt.js"
+ ),
+ "should match case-insensitively"
+ );
+
+ assert!(
+ !GptIntegration::is_gpt_script_url("https://example.com/script.js"),
+ "should not match unrelated URLs"
+ );
+
+ assert!(
+ !GptIntegration::is_gpt_script_url(
+ "https://securepubads.g.doubleclick.net/other/script.js"
+ ),
+ "should not match other doubleclick paths"
+ );
+
+ assert!(
+ !GptIntegration::is_gpt_script_url(
+ "https://cdn.example.com/loader.js?ref=securepubads.g.doubleclick.net/tag/js/gpt.js"
+ ),
+ "should not match when GPT host appears only in query text"
+ );
+
+ assert!(
+ !GptIntegration::is_gpt_script_url(
+ "https://cdn.example.com/assets/securepubads.g.doubleclick.net/tag/js/gpt.js"
+ ),
+ "should not match when GPT host appears only in path text"
+ );
+ }
+
+ // -- Attribute rewriter --
+
+ #[test]
+ fn attribute_rewriter_rewrites_gpt_urls() {
+ let integration = GptIntegration::new(test_config());
+ let ctx = test_context();
+
+ let result = integration.rewrite(
+ "src",
+ "https://securepubads.g.doubleclick.net/tag/js/gpt.js",
+ &ctx,
+ );
+
+ match result {
+ AttributeRewriteAction::Replace(url) => {
+ assert_eq!(
+ url, "https://edge.example.com/integrations/gpt/script",
+ "should rewrite to first-party script endpoint"
+ );
+ }
+ other => panic!("Expected Replace action, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn attribute_rewriter_keeps_non_gpt_urls() {
+ let integration = GptIntegration::new(test_config());
+ let ctx = test_context();
+
+ let result = integration.rewrite("src", "https://cdn.example.com/analytics.js", &ctx);
+
+ assert_eq!(
+ result,
+ AttributeRewriteAction::Keep,
+ "should keep non-GPT URLs unchanged"
+ );
+ }
+
+ #[test]
+ fn attribute_rewriter_noop_when_disabled() {
+ let config = GptConfig {
+ rewrite_script: false,
+ ..test_config()
+ };
+ let integration = GptIntegration::new(config);
+ let ctx = test_context();
+
+ let result = integration.rewrite(
+ "src",
+ "https://securepubads.g.doubleclick.net/tag/js/gpt.js",
+ &ctx,
+ );
+
+ assert_eq!(
+ result,
+ AttributeRewriteAction::Keep,
+ "should keep GPT URLs when rewrite_script is disabled"
+ );
+ }
+
+ #[test]
+ fn handles_attribute_respects_config() {
+ let enabled = GptIntegration::new(test_config());
+ assert!(
+ enabled.handles_attribute("src"),
+ "should handle src when rewrite_script is true"
+ );
+ assert!(
+ enabled.handles_attribute("href"),
+ "should handle href when rewrite_script is true"
+ );
+ assert!(
+ !enabled.handles_attribute("action"),
+ "should not handle action attribute"
+ );
+
+ let disabled = GptIntegration::new(GptConfig {
+ rewrite_script: false,
+ ..test_config()
+ });
+ assert!(
+ !disabled.handles_attribute("src"),
+ "should not handle src when rewrite_script is false"
+ );
+ }
+
+ // -- Request header forwarding --
+
+ #[test]
+ fn copy_accept_headers_forwards_all_negotiation_headers() {
+ let mut inbound = Request::new(Method::GET, "https://publisher.example/page");
+ inbound.set_header(header::ACCEPT, "application/javascript");
+ inbound.set_header(header::ACCEPT_ENCODING, "br, gzip");
+ inbound.set_header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9");
+
+ let mut upstream = Request::new(
+ Method::GET,
+ "https://securepubads.g.doubleclick.net/tag/js/gpt.js",
+ );
+
+ GptIntegration::copy_accept_headers(&inbound, &mut upstream);
+
+ assert_eq!(
+ upstream.get_header_str(header::ACCEPT),
+ Some("application/javascript"),
+ "should forward Accept header for content negotiation"
+ );
+ assert_eq!(
+ upstream.get_header_str(header::ACCEPT_ENCODING),
+ Some("br, gzip"),
+ "should forward Accept-Encoding from the client"
+ );
+ assert_eq!(
+ upstream.get_header_str(header::ACCEPT_LANGUAGE),
+ Some("en-US,en;q=0.9"),
+ "should forward Accept-Language header for locale negotiation"
+ );
+ assert_eq!(
+ upstream.get_header_str(header::USER_AGENT),
+ Some("TrustedServer/1.0"),
+ "should set a stable user agent for GPT upstream requests"
+ );
+ }
+
+ // -- Response header forwarding --
+
+ #[test]
+ fn copy_content_encoding_headers_sets_encoding_and_vary() {
+ let upstream = Response::from_status(StatusCode::OK)
+ .with_header(header::CONTENT_ENCODING, "br")
+ .with_header(header::VARY, "Accept-Language");
+ let mut downstream = Response::from_status(StatusCode::OK);
+
+ GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream);
+
+ assert_eq!(
+ downstream.get_header_str(header::CONTENT_ENCODING),
+ Some("br"),
+ "should forward Content-Encoding when upstream response is encoded"
+ );
+ assert_eq!(
+ downstream.get_header_str(header::VARY),
+ Some("Accept-Language, Accept-Encoding"),
+ "should include Accept-Encoding in Vary when forwarding encoded responses"
+ );
+ }
+
+ #[test]
+ fn copy_content_encoding_headers_preserves_existing_accept_encoding_vary() {
+ let upstream = Response::from_status(StatusCode::OK)
+ .with_header(header::CONTENT_ENCODING, "gzip")
+ .with_header(header::VARY, "Origin, Accept-Encoding");
+ let mut downstream = Response::from_status(StatusCode::OK);
+
+ GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream);
+
+ assert_eq!(
+ downstream.get_header_str(header::VARY),
+ Some("Origin, Accept-Encoding"),
+ "should preserve existing Vary value when Accept-Encoding is already present"
+ );
+ }
+
+ #[test]
+ fn copy_content_encoding_headers_skips_unencoded_responses() {
+ let upstream = Response::from_status(StatusCode::OK).with_header(header::VARY, "Origin");
+ let mut downstream = Response::from_status(StatusCode::OK);
+
+ GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream);
+
+ assert!(
+ downstream.get_header(header::CONTENT_ENCODING).is_none(),
+ "should not set Content-Encoding when upstream response is unencoded"
+ );
+ assert!(
+ downstream.get_header(header::VARY).is_none(),
+ "should not add Vary when Content-Encoding is absent"
+ );
+ }
+
+ // -- Route registration --
+
+ #[test]
+ fn routes_registered() {
+ let integration = GptIntegration::new(test_config());
+ let routes = integration.routes();
+
+ assert_eq!(routes.len(), 3, "should register three routes");
+
+ assert!(
+ routes
+ .iter()
+ .any(|r| r.path == "/integrations/gpt/script" && r.method == Method::GET),
+ "should register the bootstrap script endpoint"
+ );
+ assert!(
+ routes
+ .iter()
+ .any(|r| r.path == "/integrations/gpt/pagead/*" && r.method == Method::GET),
+ "should register the pagead wildcard proxy"
+ );
+ assert!(
+ routes
+ .iter()
+ .any(|r| r.path == "/integrations/gpt/tag/*" && r.method == Method::GET),
+ "should register the tag wildcard proxy"
+ );
+ }
+
+ // -- Build / register --
+
+ #[test]
+ fn build_requires_config() {
+ let settings = create_test_settings();
+ assert!(
+ build(&settings).is_none(),
+ "should not build without integration config"
+ );
+ }
+
+ #[test]
+ fn build_with_valid_config() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ GPT_INTEGRATION_ID,
+ &serde_json::json!({
+ "enabled": true,
+ "script_url": "https://securepubads.g.doubleclick.net/tag/js/gpt.js",
+ "cache_ttl_seconds": 3600,
+ "rewrite_script": true
+ }),
+ )
+ .expect("should insert GPT config");
+
+ assert!(
+ build(&settings).is_some(),
+ "should build with valid integration config"
+ );
+ }
+
+ #[test]
+ fn build_disabled_returns_none() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(
+ GPT_INTEGRATION_ID,
+ &serde_json::json!({
+ "enabled": false
+ }),
+ )
+ .expect("should insert GPT config");
+
+ assert!(
+ build(&settings).is_none(),
+ "should not build when integration is disabled"
+ );
+ }
+
+ // -- Upstream URL building --
+
+ #[test]
+ fn build_upstream_url_strips_prefix_and_preserves_path() {
+ let url = GptIntegration::build_upstream_url(
+ "/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js",
+ None,
+ );
+ assert_eq!(
+ url.as_deref(),
+ Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js"),
+ "should strip the integration prefix and build the upstream URL"
+ );
+ }
+
+ #[test]
+ fn build_upstream_url_preserves_query_string() {
+ let url = GptIntegration::build_upstream_url(
+ "/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js",
+ Some("cb=123&foo=bar"),
+ );
+ assert_eq!(
+ url.as_deref(),
+ Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js?cb=123&foo=bar"),
+ "should preserve the query string in the upstream URL"
+ );
+ }
+
+ #[test]
+ fn build_upstream_url_handles_tag_routes() {
+ let url =
+ GptIntegration::build_upstream_url("/integrations/gpt/tag/js/gpt.js", Some("v=2"));
+ assert_eq!(
+ url.as_deref(),
+ Some("https://securepubads.g.doubleclick.net/tag/js/gpt.js?v=2"),
+ "should handle /tag/* routes correctly"
+ );
+ }
+
+ #[test]
+ fn build_upstream_url_returns_none_for_invalid_prefix() {
+ let url = GptIntegration::build_upstream_url("/some/other/path", None);
+ assert!(
+ url.is_none(),
+ "should return None when path does not start with the integration prefix"
+ );
+ }
+
+ #[test]
+ fn build_upstream_url_handles_empty_path_after_prefix() {
+ let url = GptIntegration::build_upstream_url("/integrations/gpt", None);
+ assert_eq!(
+ url.as_deref(),
+ Some("https://securepubads.g.doubleclick.net"),
+ "should handle path that is exactly the prefix"
+ );
+ }
+
+ // -- Vary header edge cases --
+
+ #[test]
+ fn vary_with_accept_encoding_wildcard() {
+ let result = GptIntegration::vary_with_accept_encoding(Some("*"));
+ assert_eq!(
+ result, "*",
+ "should preserve Vary: * wildcard without appending Accept-Encoding"
+ );
+ }
+
+ #[test]
+ fn vary_with_accept_encoding_case_insensitive() {
+ let result = GptIntegration::vary_with_accept_encoding(Some("Origin, ACCEPT-ENCODING"));
+ assert_eq!(
+ result, "Origin, ACCEPT-ENCODING",
+ "should detect Accept-Encoding case-insensitively"
+ );
+ }
+
+ #[test]
+ fn vary_with_accept_encoding_adds_when_missing() {
+ let result = GptIntegration::vary_with_accept_encoding(Some("Origin"));
+ assert_eq!(
+ result, "Origin, Accept-Encoding",
+ "should append Accept-Encoding when not present"
+ );
+ }
+
+ #[test]
+ fn vary_with_accept_encoding_empty_upstream() {
+ let result = GptIntegration::vary_with_accept_encoding(None);
+ assert_eq!(
+ result, "Accept-Encoding",
+ "should use Accept-Encoding as default when upstream has no Vary"
+ );
+ }
+
+ #[test]
+ fn vary_with_accept_encoding_empty_string() {
+ let result = GptIntegration::vary_with_accept_encoding(Some(""));
+ assert_eq!(
+ result, "Accept-Encoding",
+ "should treat empty string the same as absent Vary"
+ );
+ }
+
+ // -- Head injector --
+
+ #[test]
+ fn head_injector_emits_enable_flag() {
+ let integration = GptIntegration::new(test_config());
+ let doc_state = IntegrationDocumentState::default();
+ let ctx = IntegrationHtmlContext {
+ request_host: "edge.example.com",
+ request_scheme: "https",
+ origin_host: "example.com",
+ document_state: &doc_state,
+ };
+
+ let inserts = integration.head_inserts(&ctx);
+
+ assert_eq!(inserts.len(), 1, "should emit exactly one head insert");
+ assert_eq!(
+ inserts[0], "",
+ "should set __tsjs_gpt_enabled flag for the client-side GPT shim"
+ );
+ }
+
+ #[test]
+ fn head_injector_integration_id() {
+ let integration = GptIntegration::new(test_config());
+ assert_eq!(
+ IntegrationHeadInjector::integration_id(integration.as_ref()),
+ "gpt"
+ );
+ }
+}
diff --git a/crates/common/src/integrations/mod.rs b/crates/common/src/integrations/mod.rs
index 464c36dd..588b9e3a 100644
--- a/crates/common/src/integrations/mod.rs
+++ b/crates/common/src/integrations/mod.rs
@@ -6,6 +6,7 @@ pub mod adserver_mock;
pub mod aps;
pub mod datadome;
pub mod didomi;
+pub mod gpt;
pub mod lockr;
pub mod nextjs;
pub mod permutive;
@@ -32,5 +33,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] {
lockr::register,
didomi::register,
datadome::register,
+ gpt::register,
]
}
diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts
new file mode 100644
index 00000000..00c68d9f
--- /dev/null
+++ b/crates/js/lib/src/integrations/gpt/index.ts
@@ -0,0 +1,170 @@
+import { log } from '../../core/log';
+
+import { installGptGuard } from './script_guard';
+
+/**
+ * Google Publisher Tags (GPT) Integration Shim
+ *
+ * Hooks into the googletag.cmd command queue so the Trusted Server can
+ * observe and augment ad-slot definitions before GPT processes them.
+ * The shim ensures the googletag stub exists early (matching GPT's own
+ * bootstrap pattern) and patches `cmd.push` to wrap queued callbacks.
+ *
+ * Current capabilities:
+ * - Installs a script guard that rewrites dynamically inserted GPT
+ * ``
+ *
+ * Hostname verification still happens in [`maybeRewrite`], so URLs that merely
+ * contain the token in query text are left unchanged.
+ */
+const SCRIPT_SRC_RE =
+ /(');
+ }).not.toThrow();
+ });
+
+ it('rewrites document.write script URLs by hostname', () => {
+ const nativeWriteSpy = vi.fn<(...args: string[]) => void>();
+ document.write = nativeWriteSpy as unknown as typeof document.write;
+
+ installGptGuard();
+
+ document.write(
+ ''
+ );
+
+ expect(nativeWriteSpy).toHaveBeenCalledTimes(1);
+ const [writtenHtml] = nativeWriteSpy.mock.calls[0] ?? [];
+ expect(writtenHtml).toContain(window.location.host);
+ expect(writtenHtml).toContain(
+ '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar'
+ );
+ });
+
+ it('does not rewrite document.write URLs that only mention GPT domains in query text', () => {
+ const nativeWriteSpy = vi.fn<(...args: string[]) => void>();
+ document.write = nativeWriteSpy as unknown as typeof document.write;
+
+ installGptGuard();
+
+ const originalHtml =
+ '';
+
+ document.write(originalHtml);
+
+ expect(nativeWriteSpy).toHaveBeenCalledTimes(1);
+ expect(nativeWriteSpy).toHaveBeenCalledWith(originalHtml);
+ });
+
+ it('rewrites through instance patch when src descriptor install is unavailable', () => {
+ const nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
+ const descriptorSpy = vi
+ .spyOn(Object, 'getOwnPropertyDescriptor')
+ .mockImplementation(
+ (target: object, property: PropertyKey): PropertyDescriptor | undefined => {
+ if (target === HTMLScriptElement.prototype && property === 'src') {
+ return undefined;
+ }
+ return nativeGetOwnPropertyDescriptor(target, property);
+ }
+ );
+
+ try {
+ installGptGuard();
+
+ const script = document.createElement('script');
+ script.src =
+ 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js';
+
+ expect(script.getAttribute('src')).toContain(
+ '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js'
+ );
+ } finally {
+ descriptorSpy.mockRestore();
+ }
+ });
+
+ it('does not rewrite URLs that only mention GPT domains in query text', () => {
+ installGptGuard();
+
+ const container = document.createElement('div');
+ const script = document.createElement('script');
+ const originalUrl =
+ 'https://cdn.example.com/loader.js?ref=securepubads.g.doubleclick.net/tag/js/gpt.js';
+
+ script.src = originalUrl;
+ container.appendChild(script);
+
+ expect(script.src).toBe(originalUrl);
+ });
+
+ it('rewrites GPT URLs by hostname', () => {
+ installGptGuard();
+
+ const container = document.createElement('div');
+ const script = document.createElement('script');
+
+ script.src =
+ 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar';
+ container.appendChild(script);
+
+ expect(script.src).toContain(window.location.host);
+ expect(script.src).toContain(
+ '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar'
+ );
+ });
+});
diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md
new file mode 100644
index 00000000..a51d3b8d
--- /dev/null
+++ b/docs/guide/integrations/gpt.md
@@ -0,0 +1,158 @@
+# Google Publisher Tags (GPT) Integration
+
+**Category**: Ad Serving
+**Status**: Production
+**Type**: First-Party Ad Tag Delivery
+
+## Overview
+
+The GPT integration enables first-party delivery of Google Publisher Tags by proxying GPT's entire script cascade through the publisher's domain. This eliminates third-party script loads, improving performance and reducing exposure to ad blockers and browser privacy restrictions.
+
+## What is GPT?
+
+Google Publisher Tags (GPT) is the JavaScript library publishers use to define and render ad slots served by Google Ad Manager. GPT loads scripts in a cascade:
+
+1. `gpt.js` -- the thin bootstrap loader
+2. `pubads_impl.js` -- the main GPT implementation (~640 KB)
+3. `pubads_impl_*.js` -- lazy-loaded sub-modules (page-level ads, side rails, etc.)
+4. Auxiliary scripts -- viewability, monitoring, error reporting
+
+All of these are served from `securepubads.g.doubleclick.net`.
+
+## How It Works
+
+```
+ Publisher HTML
+ │
+ ├─