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 = + /(]*?\bsrc\s*=\s*["'])([^"']*securepubads\.g\.doubleclick\.net[^"']*)(["'])/gi; + +/** + * Rewrite GPT domain URLs inside raw HTML strings passed to + * `document.write` / `document.writeln`. + */ +function rewriteHtmlString(html: string): string { + SCRIPT_SRC_RE.lastIndex = 0; + if (!SCRIPT_SRC_RE.test(html)) return html; + SCRIPT_SRC_RE.lastIndex = 0; + + return html.replace(SCRIPT_SRC_RE, (_match, prefix: string, url: string, suffix: string) => { + const { url: rewrittenUrl, didRewrite } = maybeRewrite(url); + if (!didRewrite) { + return `${prefix}${url}${suffix}`; + } + + log.info(`${LOG_PREFIX}: rewriting document.write script src`, { + original: url, + rewritten: rewrittenUrl, + }); + return `${prefix}${rewrittenUrl}${suffix}`; + }); +} + +function installDocumentWritePatch(): void { + if (typeof document === 'undefined') return; + + nativeDocWrite = document.write; + nativeDocWriteln = document.writeln; + + document.write = function patchedWrite(this: Document, ...args: string[]): void { + const rewrittenArgs = args.map((arg) => + typeof arg === 'string' ? rewriteHtmlString(arg) : arg + ); + nativeDocWrite!.apply(this, rewrittenArgs); + }; + + document.writeln = function patchedWriteln(this: Document, ...args: string[]): void { + const rewrittenArgs = args.map((arg) => + typeof arg === 'string' ? rewriteHtmlString(arg) : arg + ); + nativeDocWriteln!.apply(this, rewrittenArgs); + }; + + log.info(`${LOG_PREFIX}: document.write/writeln patch installed`); +} + +// --------------------------------------------------------------------------- +// Layer 2: Property descriptor on HTMLScriptElement.prototype.src +// --------------------------------------------------------------------------- + +function installSrcDescriptor(): boolean { + if (typeof HTMLScriptElement === 'undefined') return false; + + const descriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); + if (!descriptor || typeof descriptor.set !== 'function') { + log.debug(`${LOG_PREFIX}: HTMLScriptElement.prototype.src has no setter, skipping descriptor`); + return false; + } + if (descriptor.configurable === false) { + log.debug(`${LOG_PREFIX}: HTMLScriptElement.prototype.src is not configurable`); + return false; + } + + nativeSrcDescriptor = descriptor; + nativeSrcSet = descriptor.set; + nativeSrcGet = typeof descriptor.get === 'function' ? descriptor.get : undefined; + + try { + Object.defineProperty(HTMLScriptElement.prototype, 'src', { + configurable: true, + enumerable: descriptor.enumerable ?? true, + get(this: HTMLScriptElement): string { + if (nativeSrcGet) { + return nativeSrcGet.call(this); + } + return this.getAttribute('src') ?? ''; + }, + set(this: HTMLScriptElement, value: string) { + const raw = String(value ?? ''); + const { url: finalUrl, didRewrite } = maybeRewrite(raw); + if (didRewrite && !alreadyRewritten(this, finalUrl)) { + log.info(`${LOG_PREFIX}: intercepted src setter`, { original: raw, rewritten: finalUrl }); + rewritten.set(this, finalUrl); + applySrc(this, finalUrl); + } else { + applySrc(this, raw); + } + }, + }); + log.info(`${LOG_PREFIX}: src property descriptor installed`); + return true; + } catch (err) { + log.debug(`${LOG_PREFIX}: failed to install src descriptor`, err); + return false; + } +} + +// --------------------------------------------------------------------------- +// Layer 3: setAttribute patch on HTMLScriptElement.prototype +// --------------------------------------------------------------------------- + +// Track instance-level src patching to avoid redundant work. +let instancePatched = new WeakSet(); + +/** + * Install a per-instance `src` property descriptor on a script element. + * Used as a fallback when the prototype-level descriptor cannot be + * installed, or as belt-and-suspenders from `document.createElement`. + */ +function ensureInstancePatched(element: HTMLScriptElement): void { + if (instancePatched.has(element)) return; + instancePatched.add(element); + + try { + Object.defineProperty(element, 'src', { + configurable: true, + enumerable: true, + get(this: HTMLScriptElement): string { + if (nativeSrcGet) { + return nativeSrcGet.call(this); + } + return this.getAttribute('src') ?? ''; + }, + set(this: HTMLScriptElement, value: string) { + const raw = String(value ?? ''); + const { url: finalUrl, didRewrite } = maybeRewrite(raw); + if (didRewrite && !alreadyRewritten(this, finalUrl)) { + log.info(`${LOG_PREFIX}: intercepted instance src setter`, { + original: raw, + rewritten: finalUrl, + }); + rewritten.set(this, finalUrl); + applySrc(this, finalUrl); + } else { + applySrc(this, raw); + } + }, + }); + } catch { + // Instance-level defineProperty can fail in some environments. + } +} + +function installSetAttributePatch(): void { + if (typeof HTMLScriptElement === 'undefined') return; + + nativeSetAttribute = HTMLScriptElement.prototype.setAttribute; + + HTMLScriptElement.prototype.setAttribute = function patchedSetAttribute( + this: HTMLScriptElement, + name: string, + value: string + ): void { + if (typeof name === 'string' && name.toLowerCase() === 'src') { + const raw = String(value ?? ''); + const { url: finalUrl, didRewrite } = maybeRewrite(raw); + if (didRewrite && !alreadyRewritten(this, finalUrl)) { + log.info(`${LOG_PREFIX}: intercepted setAttribute('src')`, { + original: raw, + rewritten: finalUrl, + }); + rewritten.set(this, finalUrl); + nativeSetAttribute!.call(this, name, finalUrl); + return; + } + } + nativeSetAttribute!.call(this, name, value); + }; + + log.info(`${LOG_PREFIX}: setAttribute patch installed`); +} + +// --------------------------------------------------------------------------- +// Layer 4: document.createElement patch +// --------------------------------------------------------------------------- + +/** + * Patch `document.createElement` so that every newly created `'); + }).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 + │ + ├─