diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts
new file mode 100644
index 00000000..acfe76e5
--- /dev/null
+++ b/crates/js/lib/src/integrations/sourcepoint/index.ts
@@ -0,0 +1,23 @@
+import { log } from '../../core/log';
+
+import { installSourcepointGuard } from './script_guard';
+
+type SourcepointWindow = Window & {
+ __tsjs_sourcepoint?: {
+ rewriteSdk?: boolean;
+ };
+};
+
+function shouldInstallSourcepointGuard(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ const config = (window as SourcepointWindow).__tsjs_sourcepoint;
+ return config?.rewriteSdk !== false;
+}
+
+if (typeof window !== 'undefined' && shouldInstallSourcepointGuard()) {
+ installSourcepointGuard();
+ log.info('Sourcepoint integration initialized');
+}
diff --git a/crates/js/lib/src/integrations/sourcepoint/script_guard.ts b/crates/js/lib/src/integrations/sourcepoint/script_guard.ts
new file mode 100644
index 00000000..3e1493bd
--- /dev/null
+++ b/crates/js/lib/src/integrations/sourcepoint/script_guard.ts
@@ -0,0 +1,56 @@
+import { createScriptGuard } from '../../shared/script_guard';
+
+const SOURCEPOINT_CDN_HOST = 'cdn.privacy-mgmt.com';
+
+function normalizeSourcepointUrl(url: string): string | null {
+ if (!url) return null;
+
+ const trimmed = url.trim();
+ if (!trimmed) return null;
+
+ if (trimmed.startsWith('//')) return `https:${trimmed}`;
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed;
+
+ // Keep in sync with Rust `parse_sourcepoint_url()` in:
+ // crates/trusted-server-core/src/integrations/sourcepoint.rs
+ // (protocol-relative + bare-host handling + hostname-only host checks).
+ // Bare domain or path — attempt to parse as https URL.
+ // The hostname === check in isSourcepointUrl rejects non-matching domains.
+ return `https://${trimmed}`;
+}
+
+function parseSourcepointUrl(url: string): URL | null {
+ const normalized = normalizeSourcepointUrl(url);
+ if (!normalized) return null;
+
+ try {
+ return new URL(normalized);
+ } catch {
+ return null;
+ }
+}
+
+export function isSourcepointUrl(url: string): boolean {
+ const parsed = parseSourcepointUrl(url);
+ return parsed?.hostname === SOURCEPOINT_CDN_HOST;
+}
+
+export function rewriteSourcepointUrl(originalUrl: string): string {
+ const parsed = parseSourcepointUrl(originalUrl);
+ if (!parsed) return originalUrl;
+
+ const query = parsed.search || '';
+
+ return `${window.location.origin}/integrations/sourcepoint/cdn${parsed.pathname}${query}`;
+}
+
+const guard = createScriptGuard({
+ displayName: 'Sourcepoint',
+ id: 'sourcepoint',
+ isTargetUrl: isSourcepointUrl,
+ rewriteUrl: rewriteSourcepointUrl,
+});
+
+export const installSourcepointGuard = guard.install;
+export const isGuardInstalled = guard.isInstalled;
+export const resetGuardState = guard.reset;
diff --git a/crates/js/lib/test/integrations/sourcepoint/index.test.ts b/crates/js/lib/test/integrations/sourcepoint/index.test.ts
new file mode 100644
index 00000000..f8117bc7
--- /dev/null
+++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+type SourcepointWindow = Window & {
+ __tsjs_sourcepoint?: {
+ rewriteSdk?: boolean;
+ };
+ __tsjs_installSourcepointGuard?: unknown;
+};
+
+describe('Sourcepoint integration initialization', () => {
+ let win: SourcepointWindow;
+
+ beforeEach(async () => {
+ win = window as SourcepointWindow;
+ delete win.__tsjs_sourcepoint;
+
+ const guard = await import('../../../src/integrations/sourcepoint/script_guard');
+ guard.resetGuardState();
+ });
+
+ afterEach(async () => {
+ const guard = await import('../../../src/integrations/sourcepoint/script_guard');
+ guard.resetGuardState();
+ delete win.__tsjs_sourcepoint;
+ delete win.__tsjs_installSourcepointGuard;
+ });
+
+ it('installs the guard when rewriteSdk is enabled', async () => {
+ vi.resetModules();
+ win.__tsjs_sourcepoint = { rewriteSdk: true };
+
+ const guard = await import('../../../src/integrations/sourcepoint/script_guard');
+ await import('../../../src/integrations/sourcepoint/index');
+
+ expect(guard.isGuardInstalled()).toBe(true);
+ });
+
+ it('skips the guard when rewriteSdk is disabled', async () => {
+ vi.resetModules();
+ win.__tsjs_sourcepoint = { rewriteSdk: false };
+
+ const guard = await import('../../../src/integrations/sourcepoint/script_guard');
+ await import('../../../src/integrations/sourcepoint/index');
+
+ expect(guard.isGuardInstalled()).toBe(false);
+ });
+
+ it('defaults to installing the guard when rewriteSdk is missing for backward compatibility', async () => {
+ vi.resetModules();
+
+ const guard = await import('../../../src/integrations/sourcepoint/script_guard');
+ await import('../../../src/integrations/sourcepoint/index');
+
+ expect(guard.isGuardInstalled()).toBe(true);
+ });
+});
diff --git a/crates/js/lib/test/integrations/sourcepoint/script_guard.test.ts b/crates/js/lib/test/integrations/sourcepoint/script_guard.test.ts
new file mode 100644
index 00000000..f68bc074
--- /dev/null
+++ b/crates/js/lib/test/integrations/sourcepoint/script_guard.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ installSourcepointGuard,
+ isGuardInstalled,
+ isSourcepointUrl,
+ resetGuardState,
+ rewriteSourcepointUrl,
+} from '../../../src/integrations/sourcepoint/script_guard';
+
+describe('Sourcepoint SDK Script Interception Guard', () => {
+ let originalAppendChild: typeof Element.prototype.appendChild;
+ let originalInsertBefore: typeof Element.prototype.insertBefore;
+
+ beforeEach(() => {
+ resetGuardState();
+ originalAppendChild = Element.prototype.appendChild;
+ originalInsertBefore = Element.prototype.insertBefore;
+ });
+
+ afterEach(() => {
+ resetGuardState();
+ });
+
+ it('detects Sourcepoint CDN URLs', () => {
+ expect(isSourcepointUrl('https://cdn.privacy-mgmt.com/wrapper/v2/messages')).toBe(true);
+ expect(isSourcepointUrl('//cdn.privacy-mgmt.com/mms/v2/get_site_data')).toBe(true);
+ expect(isSourcepointUrl('cdn.privacy-mgmt.com/consent/tcfv2')).toBe(true);
+ expect(isSourcepointUrl('https://cdn.privacy-mgmt.com:443/wrapper/v2/messages')).toBe(true);
+ expect(isSourcepointUrl('http://cdn.privacy-mgmt.com:80/consent/tcfv2')).toBe(true);
+ expect(isSourcepointUrl('https://cdn.privacy-mgmt.com:8443/wrapper/v2/messages')).toBe(true);
+ expect(isSourcepointUrl('https://example.com/script.js')).toBe(false);
+ expect(isSourcepointUrl('https://geo.privacymanager.io/')).toBe(false);
+ });
+
+ it('rejects subdomain-spoofing URLs', () => {
+ expect(isSourcepointUrl('cdn.privacy-mgmt.com.evil.com/script.js')).toBe(false);
+ expect(isSourcepointUrl('https://cdn.privacy-mgmt.com.evil.com/')).toBe(false);
+ expect(isSourcepointUrl('notcdn.privacy-mgmt.com/path')).toBe(false);
+ });
+
+ it('rewrites CDN URLs to the first-party proxy path', () => {
+ expect(rewriteSourcepointUrl('https://cdn.privacy-mgmt.com/wrapper/v2/messages?env=prod')).toBe(
+ `${window.location.origin}/integrations/sourcepoint/cdn/wrapper/v2/messages?env=prod`
+ );
+ });
+
+ it('installs and resets the guard', () => {
+ expect(isGuardInstalled()).toBe(false);
+ installSourcepointGuard();
+ expect(isGuardInstalled()).toBe(true);
+ expect(Element.prototype.appendChild).not.toBe(originalAppendChild);
+ expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore);
+ resetGuardState();
+ expect(Element.prototype.appendChild).toBe(originalAppendChild);
+ expect(Element.prototype.insertBefore).toBe(originalInsertBefore);
+ });
+
+ it('rewrites dynamically inserted Sourcepoint scripts', () => {
+ installSourcepointGuard();
+
+ const container = document.createElement('div');
+ const script = document.createElement('script');
+ script.src = 'https://cdn.privacy-mgmt.com/wrapperMessagingWithoutDetection.js';
+
+ container.appendChild(script);
+
+ expect(script.src).toContain(
+ '/integrations/sourcepoint/cdn/wrapperMessagingWithoutDetection.js'
+ );
+ expect(script.src).not.toContain('cdn.privacy-mgmt.com');
+ });
+
+ it('does not rewrite unrelated scripts', () => {
+ installSourcepointGuard();
+
+ const container = document.createElement('div');
+ const script = document.createElement('script');
+ script.src = 'https://example.com/app.js';
+
+ container.appendChild(script);
+
+ expect(script.src).toBe('https://example.com/app.js');
+ });
+});
diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs
index 92f30219..29657166 100644
--- a/crates/trusted-server-core/src/integrations/mod.rs
+++ b/crates/trusted-server-core/src/integrations/mod.rs
@@ -16,6 +16,7 @@ pub mod nextjs;
pub mod permutive;
pub mod prebid;
mod registry;
+pub mod sourcepoint;
pub mod testlight;
pub use registry::{
@@ -37,6 +38,7 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] {
permutive::register,
lockr::register,
didomi::register,
+ sourcepoint::register,
google_tag_manager::register,
datadome::register,
gpt::register,
diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs
new file mode 100644
index 00000000..43071cca
--- /dev/null
+++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs
@@ -0,0 +1,1356 @@
+//! Sourcepoint integration for first-party CMP (Consent Management Platform) delivery.
+//!
+//! Proxies Sourcepoint's CDN (`cdn.privacy-mgmt.com`) through Trusted Server so
+//! the browser loads consent management assets from first-party paths.
+//!
+//! ## Rewriting layers
+//!
+//! | Layer | Mechanism | What it catches |
+//! |-------|-----------|-----------------|
+//! | HTML attributes | `IntegrationAttributeRewriter` | Static `",
+ self.config.rewrite_sdk
+ )];
+
+ if !self.config.rewrite_sdk {
+ return inserts;
+ }
+
+ // Install a property trap on `window._sp_` so that when the
+ // publisher's code (typically a Next.js hydration chunk) sets the
+ // Sourcepoint config object, we intercept it and rewrite any
+ // `cdn.privacy-mgmt.com` URLs to the first-party proxy prefix.
+ //
+ // The trap is transparent: the getter returns the (patched) value and
+ // the setter accepts any shape the SDK expects. We also handle the
+ // case where `window._sp_` is already set before our script runs.
+ //
+ // Limitations:
+ // - Only intercepts top-level assignment (`window._sp_ = …`). Nested
+ // mutation like `window._sp_.config.baseEndpoint = "…"` after the
+ // initial assignment is not caught. The JS body regex rewriter
+ // covers that case for string literals in bundled code.
+ // - `s.replace()` replaces only the first occurrence per call, which
+ // is fine for the current set of scalar URL config fields.
+ inserts.push(format!(
+ concat!(
+ "",
+ ),
+ cdn_host = SOURCEPOINT_CDN_HOST,
+ cdn_prefix = SOURCEPOINT_CDN_PREFIX,
+ ));
+
+ inserts
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::integrations::{IntegrationDocumentState, IntegrationRegistry};
+ use crate::test_support::tests::create_test_settings;
+ use fastly::http::Method;
+ use serde_json::json;
+
+ fn config(enabled: bool) -> SourcepointConfig {
+ SourcepointConfig {
+ enabled,
+ rewrite_sdk: true,
+ cdn_origin: default_cdn_origin(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ }
+ }
+
+ #[test]
+ fn strips_cdn_prefix_from_routes() {
+ assert_eq!(
+ SourcepointIntegration::strip_cdn_prefix(
+ "/integrations/sourcepoint/cdn/wrapper/v2/messages"
+ ),
+ Some("/wrapper/v2/messages")
+ );
+ assert_eq!(
+ SourcepointIntegration::strip_cdn_prefix("/integrations/sourcepoint/cdn"),
+ Some("/")
+ );
+ assert_eq!(
+ SourcepointIntegration::strip_cdn_prefix("/some/other/path"),
+ None
+ );
+ }
+
+ #[test]
+ fn rewrites_cdn_urls_to_first_party_paths() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let ctx = IntegrationAttributeContext {
+ attribute_name: "src",
+ request_host: "edge.example.com",
+ request_scheme: "https",
+ origin_host: "origin.example.com",
+ };
+
+ let rewritten = integration.rewrite(
+ "src",
+ "https://cdn.privacy-mgmt.com/mms/v2/get_site_data?account_id=821",
+ &ctx,
+ );
+
+ assert_eq!(
+ rewritten,
+ AttributeRewriteAction::replace(
+ "https://edge.example.com/integrations/sourcepoint/cdn/mms/v2/get_site_data?account_id=821",
+ )
+ );
+ }
+
+ #[test]
+ fn leaves_non_sourcepoint_urls_unchanged() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let ctx = IntegrationAttributeContext {
+ attribute_name: "src",
+ request_host: "edge.example.com",
+ request_scheme: "https",
+ origin_host: "origin.example.com",
+ };
+
+ assert_eq!(
+ integration.rewrite("src", "https://example.com/script.js", &ctx),
+ AttributeRewriteAction::keep()
+ );
+ }
+
+ #[test]
+ fn rewrites_quoted_cdn_urls_to_root_relative_paths() {
+ let input = r#"var fallback="https://cdn.privacy-mgmt.com";var api="https://cdn.privacy-mgmt.com/consent/tcfv2";"#;
+ let output = SourcepointIntegration::rewrite_script_content(input);
+
+ assert_eq!(
+ output,
+ r#"var fallback="/integrations/sourcepoint/cdn";var api="/integrations/sourcepoint/cdn/consent/tcfv2";"#
+ );
+ }
+
+ #[test]
+ fn rewrites_protocol_relative_cdn_urls() {
+ let input = r#"url="//cdn.privacy-mgmt.com/mms/v2/get_site_data""#;
+ let output = SourcepointIntegration::rewrite_script_content(input);
+
+ assert!(
+ output.contains("\"/integrations/sourcepoint/cdn/mms/v2/get_site_data\""),
+ "Should rewrite protocol-relative CDN URL. Got: {output}",
+ );
+ }
+
+ #[test]
+ fn rewrites_origin_plus_unified_chunk_pattern() {
+ let input = r#"return t.origin+"/unified/4.40.1/"}"#;
+ let output = SourcepointIntegration::rewrite_script_content(input);
+
+ assert_eq!(
+ output,
+ r#"return t.origin+"/integrations/sourcepoint/cdn/unified/4.40.1/"}"#
+ );
+ }
+
+ #[test]
+ fn rewrites_both_patterns_in_realistic_snippet() {
+ // Mirrors the real Sourcepoint webpack public path resolution:
+ // try { ... return t.origin+"/unified/4.40.1/" }
+ // catch(e) {} return e+"/unified/4.40.1/"
+ // where e defaults to "https://cdn.privacy-mgmt.com"
+ let input = concat!(
+ r#"var e="https://cdn.privacy-mgmt.com";"#,
+ r#"try{var t=document.createElement("a");"#,
+ r#"t.href=document.currentScript.src;"#,
+ r#"return t.origin+"/unified/4.40.1/"}"#,
+ r#"catch(n){}return e+"/unified/4.40.1/""#,
+ );
+
+ let output = SourcepointIntegration::rewrite_script_content(input);
+
+ assert!(
+ output.contains(r#"var e="/integrations/sourcepoint/cdn";"#),
+ "Fallback CDN default should be rewritten. Got: {output}",
+ );
+ assert!(
+ output.contains(r#"t.origin+"/integrations/sourcepoint/cdn/unified/4.40.1/"}"#),
+ "Origin chunk path should be prefixed. Got: {output}",
+ );
+ assert!(
+ output.contains(r#"e+"/unified/4.40.1/""#),
+ "Fallback concatenation should keep /unified/ since e is already rewritten. Got: {output}",
+ );
+ }
+
+ #[test]
+ fn preserves_non_sourcepoint_urls() {
+ let input = r#"var cdn="https://example.com/script.js";var x=t.origin+"/assets/app.js""#;
+ let output = SourcepointIntegration::rewrite_script_content(input);
+
+ assert_eq!(output, input, "Non-Sourcepoint URLs should be untouched");
+ }
+
+ #[test]
+ fn registers_sourcepoint_routes() {
+ let mut settings = create_test_settings();
+ settings
+ .integrations
+ .insert_config(SOURCEPOINT_INTEGRATION_ID, &json!({ "enabled": true }))
+ .expect("should insert config");
+
+ let registry = IntegrationRegistry::new(&settings).expect("should create registry");
+ assert!(
+ registry.has_route(
+ &Method::GET,
+ "/integrations/sourcepoint/cdn/wrapper/v2/messages"
+ ),
+ "should register CDN proxy route"
+ );
+ }
+
+ #[test]
+ fn attribute_rewriter_skips_when_rewrite_disabled() {
+ let mut cfg = config(true);
+ cfg.rewrite_sdk = false;
+ let integration = SourcepointIntegration::new(Arc::new(cfg));
+
+ assert!(
+ !integration.handles_attribute("src"),
+ "should not handle src when rewrite_sdk is false"
+ );
+ assert!(
+ !integration.handles_attribute("href"),
+ "should not handle href when rewrite_sdk is false"
+ );
+ }
+
+ #[test]
+ fn identifies_likely_javascript_paths() {
+ assert!(SourcepointIntegration::is_likely_javascript_path(
+ "/unified/4.40.1/gdpr-tcf.bundle.js"
+ ));
+ assert!(SourcepointIntegration::is_likely_javascript_path(
+ "/wrapper/v2/messages"
+ ));
+ assert!(SourcepointIntegration::is_likely_javascript_path(
+ "/wrapperMessagingWithoutDetection.js"
+ ));
+ assert!(!SourcepointIntegration::is_likely_javascript_path(
+ "/mms/v2/get_site_data"
+ ));
+ assert!(!SourcepointIntegration::is_likely_javascript_path(
+ "/consent/tcfv2"
+ ));
+ }
+
+ #[test]
+ fn head_injector_emits_config_script_plus_trap_when_enabled() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let document_state = IntegrationDocumentState::default();
+ let ctx = IntegrationHtmlContext {
+ request_host: "ts.autoblog.com",
+ request_scheme: "https",
+ origin_host: "origin.autoblog.com",
+ document_state: &document_state,
+ };
+
+ let inserts = integration.head_inserts(&ctx);
+ assert_eq!(
+ inserts.len(),
+ 2,
+ "should emit config plus trap script when enabled"
+ );
+
+ let config_script = &inserts[0];
+ assert!(
+ config_script.contains("window.__tsjs_sourcepoint={\"rewriteSdk\":true}"),
+ "should emit rewrite SDK config script: {config_script}"
+ );
+
+ let trap_script = &inserts[1];
+ assert!(
+ trap_script.starts_with(""),
+ "should be wrapped in script tags: {trap_script}",
+ );
+ assert!(
+ trap_script.contains("cdn.privacy-mgmt.com"),
+ "should reference the CDN host to rewrite: {trap_script}",
+ );
+ assert!(
+ trap_script.contains("/integrations/sourcepoint/cdn"),
+ "should contain the first-party CDN prefix: {trap_script}",
+ );
+ assert!(
+ trap_script.contains("Object.defineProperty"),
+ "should install a property trap on window._sp_: {trap_script}",
+ );
+ assert!(
+ trap_script.contains("baseEndpoint"),
+ "should patch baseEndpoint in the config: {trap_script}",
+ );
+ assert!(
+ trap_script.contains("metricUrl"),
+ "should patch metricUrl: {trap_script}",
+ );
+ }
+
+ #[test]
+ fn head_injector_returns_config_when_rewrite_disabled() {
+ let mut cfg = config(true);
+ cfg.rewrite_sdk = false;
+ let integration = SourcepointIntegration::new(Arc::new(cfg));
+ let document_state = IntegrationDocumentState::default();
+ let ctx = IntegrationHtmlContext {
+ request_host: "ts.autoblog.com",
+ request_scheme: "https",
+ origin_host: "origin.autoblog.com",
+ document_state: &document_state,
+ };
+
+ let inserts = integration.head_inserts(&ctx);
+ assert_eq!(
+ inserts.len(),
+ 1,
+ "should emit only config script when rewrite_sdk is false"
+ );
+ assert!(
+ inserts[0].contains("window.__tsjs_sourcepoint={\"rewriteSdk\":false}"),
+ "should flag rewriteSdk false"
+ );
+ assert!(
+ !inserts[0].contains("Object.defineProperty"),
+ "should not emit runtime trap when rewrite_sdk is disabled"
+ );
+ }
+
+ #[test]
+ fn rejects_cdn_origin_outside_privacy_mgmt_domain() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "http://169.254.169.254".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(
+ cfg.validate().is_err(),
+ "should reject cdn_origin not on cdn.privacy-mgmt.com"
+ );
+ }
+
+ #[test]
+ fn rejects_cdn_origin_with_non_http_scheme() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "ftp://cdn.privacy-mgmt.com".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(cfg.validate().is_err(), "should reject non-HTTP(S) scheme");
+ }
+
+ #[test]
+ fn rejects_cdn_origin_with_different_subdomain() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "https://cdn-eu.privacy-mgmt.com".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(
+ cfg.validate().is_err(),
+ "should reject subdomain other than cdn.privacy-mgmt.com"
+ );
+ }
+
+ #[test]
+ fn rejects_cdn_origin_with_path() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "https://cdn.privacy-mgmt.com/edge".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(
+ cfg.validate().is_err(),
+ "should reject path components in cdn_origin"
+ );
+ }
+
+ #[test]
+ fn rejects_cdn_origin_with_query() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "https://cdn.privacy-mgmt.com?edge=1".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(
+ cfg.validate().is_err(),
+ "should reject query strings in cdn_origin"
+ );
+ }
+
+ #[test]
+ fn rejects_cdn_origin_with_fragment() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "https://cdn.privacy-mgmt.com#edge".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(
+ cfg.validate().is_err(),
+ "should reject fragments in cdn_origin"
+ );
+ }
+
+ #[test]
+ fn accepts_valid_cdn_origin() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "https://cdn.privacy-mgmt.com".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(
+ cfg.validate().is_ok(),
+ "should accept cdn_origin on cdn.privacy-mgmt.com"
+ );
+ }
+
+ #[test]
+ fn accepts_http_cdn_origin() {
+ let cfg = SourcepointConfig {
+ enabled: true,
+ rewrite_sdk: true,
+ cdn_origin: "http://cdn.privacy-mgmt.com".to_string(),
+ auth_cookie_name: None,
+ cache_ttl_seconds: default_cache_ttl(),
+ };
+ assert!(
+ cfg.validate().is_ok(),
+ "should accept http scheme for cdn_origin"
+ );
+ }
+
+ #[test]
+ fn forwards_only_allowlisted_sourcepoint_cookies() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint");
+ req.set_header(
+ header::COOKIE,
+ "consentUUID=uuid123; session_id=secret; euconsent-v2=tcf; _sp_su=1; theme=dark",
+ );
+
+ assert_eq!(
+ integration
+ .filtered_sourcepoint_cookie_header(&req)
+ .as_deref(),
+ Some("consentUUID=uuid123; euconsent-v2=tcf; _sp_su=1"),
+ "should forward only Sourcepoint cookie names"
+ );
+ }
+
+ #[test]
+ fn forwards_configured_auth_cookie_name() {
+ let mut cfg = config(true);
+ cfg.auth_cookie_name = Some("sp_auth".to_string());
+ let integration = SourcepointIntegration::new(Arc::new(cfg));
+ let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint");
+ req.set_header(
+ header::COOKIE,
+ "sp_auth=token123; session_id=secret; consentUUID=uuid123",
+ );
+
+ assert_eq!(
+ integration
+ .filtered_sourcepoint_cookie_header(&req)
+ .as_deref(),
+ Some("sp_auth=token123; consentUUID=uuid123"),
+ "should forward configured Sourcepoint auth cookie alongside built-in cookies"
+ );
+ }
+
+ #[test]
+ fn drops_unrelated_publisher_cookies_from_upstream_request() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint");
+ req.set_header(header::COOKIE, "session_id=secret; theme=dark");
+
+ assert_eq!(
+ integration.filtered_sourcepoint_cookie_header(&req),
+ None,
+ "should omit upstream Cookie header when no Sourcepoint cookies are present"
+ );
+ }
+
+ #[test]
+ fn apply_cache_headers_uses_private_no_store_for_cookie_setting_responses() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let mut response = Response::from_status(StatusCode::OK);
+ response.set_header(header::SET_COOKIE, "consentUUID=uuid123; Path=/");
+ response.set_header(header::CACHE_CONTROL, "public, max-age=3600");
+
+ integration.apply_cache_headers(&mut response);
+
+ assert_eq!(
+ response.get_header_str(header::CACHE_CONTROL),
+ Some("private, no-store"),
+ "should prevent public caching for cookie-setting responses"
+ );
+ }
+
+ #[test]
+ fn rewrite_javascript_response_preserves_headers() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let mut response = Response::from_status(StatusCode::OK);
+
+ response.set_header(header::VARY, "Origin");
+ response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "https://example.com");
+ response.set_header(header::CONTENT_ENCODING, "gzip");
+ response.set_header(header::CONTENT_LENGTH, "4");
+ response.set_header(header::CACHE_CONTROL, "no-store");
+
+ response.set_body("payload");
+ integration.rewrite_javascript_response(&mut response, "rewritten".to_string());
+
+ assert_eq!(response.get_status(), StatusCode::OK);
+ assert_eq!(
+ response.get_header_str(header::CONTENT_TYPE),
+ Some("application/javascript; charset=utf-8")
+ );
+ let expected_cache_control = format!("public, max-age={}", default_cache_ttl());
+ assert_eq!(
+ response.get_header_str(header::CACHE_CONTROL),
+ Some(expected_cache_control.as_str())
+ );
+ assert_eq!(response.get_header_str(header::VARY), Some("Origin"));
+ assert_eq!(
+ response.get_header_str(header::ACCESS_CONTROL_ALLOW_ORIGIN),
+ Some("https://example.com")
+ );
+ assert!(response.get_header(header::CONTENT_ENCODING).is_none());
+ assert!(response.get_header(header::CONTENT_LENGTH).is_none());
+
+ let body = response.take_body_bytes();
+ assert_eq!(
+ String::from_utf8(body).expect("should decode rewritten JavaScript response"),
+ "rewritten"
+ );
+ }
+
+ #[test]
+ fn rewrite_javascript_response_uses_private_no_store_for_cookie_setting_responses() {
+ let integration = SourcepointIntegration::new(Arc::new(config(true)));
+ let mut response = Response::from_status(StatusCode::OK);
+ response.set_header(header::SET_COOKIE, "consentUUID=uuid123; Path=/");
+ response.set_header(header::CACHE_CONTROL, "public, max-age=3600");
+ response.set_body("payload");
+
+ integration.rewrite_javascript_response(&mut response, "rewritten".to_string());
+
+ assert_eq!(
+ response.get_header_str(header::CACHE_CONTROL),
+ Some("private, no-store"),
+ "should avoid public caching when rewritten response still sets cookies"
+ );
+ assert_eq!(
+ response.get_header_str(header::CONTENT_TYPE),
+ Some("application/javascript; charset=utf-8")
+ );
+ }
+
+ #[test]
+ fn rewrites_single_quoted_origin_plus_unified_pattern() {
+ let input = r#"return t.origin+'/unified/4.40.1/'}"#;
+ let output = SourcepointIntegration::rewrite_script_content(input);
+
+ assert_eq!(
+ output, r#"return t.origin+'/integrations/sourcepoint/cdn/unified/4.40.1/'}"#,
+ "should rewrite single-quoted unified path"
+ );
+ }
+
+ #[test]
+ fn rewrites_absolute_redirect_location() {
+ let result = SourcepointIntegration::rewrite_redirect_location(
+ "https://cdn.privacy-mgmt.com/consent/tcfv2?foo=bar",
+ "https://cdn.privacy-mgmt.com/original",
+ );
+ assert_eq!(
+ result.as_deref(),
+ Some("/integrations/sourcepoint/cdn/consent/tcfv2?foo=bar"),
+ "should rewrite absolute CDN redirect"
+ );
+ }
+
+ #[test]
+ fn rewrites_protocol_relative_redirect_location() {
+ let result = SourcepointIntegration::rewrite_redirect_location(
+ "//cdn.privacy-mgmt.com/consent/tcfv2",
+ "https://cdn.privacy-mgmt.com/original",
+ );
+ assert_eq!(
+ result.as_deref(),
+ Some("/integrations/sourcepoint/cdn/consent/tcfv2"),
+ "should rewrite protocol-relative CDN redirect"
+ );
+ }
+
+ #[test]
+ fn preserves_redirect_fragment_when_rewriting_location() {
+ let result = SourcepointIntegration::rewrite_redirect_location(
+ "https://cdn.privacy-mgmt.com/consent/tcfv2#hash",
+ "https://cdn.privacy-mgmt.com/original",
+ );
+ assert_eq!(
+ result.as_deref(),
+ Some("/integrations/sourcepoint/cdn/consent/tcfv2#hash"),
+ "should preserve fragment when rewriting redirect"
+ );
+ }
+
+ #[test]
+ fn ignores_redirect_to_other_host() {
+ let result = SourcepointIntegration::rewrite_redirect_location(
+ "https://example.com/other",
+ "https://cdn.privacy-mgmt.com/original",
+ );
+ assert_eq!(result, None, "should not rewrite redirect to non-CDN host");
+ }
+
+ #[test]
+ fn rewrites_relative_redirect_location() {
+ let result = SourcepointIntegration::rewrite_redirect_location(
+ "/consent/tcfv2/new-path",
+ "https://cdn.privacy-mgmt.com/original",
+ );
+ assert_eq!(
+ result.as_deref(),
+ Some("/integrations/sourcepoint/cdn/consent/tcfv2/new-path"),
+ "should rewrite relative redirect resolved against CDN base"
+ );
+ }
+}
diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md
index 1c312584..af6e0894 100644
--- a/docs/guide/integrations-overview.md
+++ b/docs/guide/integrations-overview.md
@@ -4,12 +4,13 @@ Trusted Server provides built-in integrations with popular third-party services,
## Quick Comparison
-| Integration | Type | Endpoints | HTML Rewriting | Primary Use Case | Status |
-| ------------- | ---------------- | ---------- | ---------------------------- | --------------------------- | ----------- |
-| **Prebid** | Proxy + Rewriter | 2-3 routes | Removes Prebid.js scripts | Server-side header bidding | Production |
-| **Next.js** | Script Rewriter | None | Rewrites Next.js data | First-party Next.js routing | Production |
-| **Permutive** | Proxy + Rewriter | 6 routes | Rewrites SDK URLs | First-party audience data | Production |
-| **Testlight** | Proxy + Rewriter | 1 route | Rewrites integration scripts | Testing/development | Development |
+| Integration | Type | Endpoints | HTML Rewriting | Primary Use Case | Status |
+| --------------- | ---------------- | ---------- | ---------------------------- | --------------------------- | ----------- |
+| **Prebid** | Proxy + Rewriter | 2-3 routes | Removes Prebid.js scripts | Server-side header bidding | Production |
+| **Next.js** | Script Rewriter | None | Rewrites Next.js data | First-party Next.js routing | Production |
+| **Permutive** | Proxy + Rewriter | 6 routes | Rewrites SDK URLs | First-party audience data | Production |
+| **Sourcepoint** | Proxy + Rewriter | 2 routes | Rewrites CMP asset URLs | First-party CMP delivery | Development |
+| **Testlight** | Proxy + Rewriter | 1 route | Rewrites integration scripts | Testing/development | Development |
## Integration Details
@@ -119,6 +120,39 @@ rewrite_sdk = true
---
+### Sourcepoint
+
+**What it does:** Proxies Sourcepoint CMP CDN endpoints through Trusted Server and rewrites publisher references to first-party paths.
+
+**Key Features:**
+
+- CDN proxy for `cdn.privacy-mgmt.com`
+- HTML attribute rewriting for Sourcepoint assets
+- JavaScript body rewriting for webpack chunks and API URLs
+- Head-injected `window._sp_` property trap for runtime config
+- Client-side script guard for dynamic script insertion
+
+**Configuration:**
+
+```toml
+[integrations.sourcepoint]
+enabled = true
+rewrite_sdk = true
+cdn_origin = "https://cdn.privacy-mgmt.com"
+# auth_cookie_name = "sp_auth"
+cache_ttl_seconds = 3600
+```
+
+**Endpoints:**
+
+- `GET/POST /integrations/sourcepoint/cdn/*` - Sourcepoint CDN proxy
+
+**When to use:** You load Sourcepoint CMP assets and want them to flow through first-party paths without introducing an open-ended proxy.
+
+**Learn more:** [Sourcepoint Integration](./integrations/sourcepoint.md)
+
+---
+
### Testlight
**What it does:** Testing/development integration for validating the integration system with OpenRTB-like auctions.
@@ -198,6 +232,10 @@ Do you use Permutive for audience data?
├─ Yes → Enable Permutive integration
└─ No → Skip Permutive
+Do you use Sourcepoint for consent management?
+├─ Yes → Enable Sourcepoint integration
+└─ No → Skip Sourcepoint
+
Are you developing/testing integrations?
├─ Yes → Enable Testlight integration
└─ No → Skip Testlight
@@ -205,12 +243,13 @@ Are you developing/testing integrations?
## Performance Considerations
-| Integration | Performance Impact | Caching Strategy | Notes |
-| ------------- | ------------------ | --------------------------- | -------------------------------------------- |
-| **Prebid** | Medium | Response caching possible | Timeout configurable (default 1s) |
-| **Next.js** | Low | N/A (streaming rewrite) | Minimal overhead, runs during HTML streaming |
-| **Permutive** | Low | SDK cached (1 hour default) | API calls proxied in real-time |
-| **Testlight** | Low | No caching | Development use only |
+| Integration | Performance Impact | Caching Strategy | Notes |
+| --------------- | ------------------ | --------------------------- | -------------------------------------------- |
+| **Prebid** | Medium | Response caching possible | Timeout configurable (default 1s) |
+| **Next.js** | Low | N/A (streaming rewrite) | Minimal overhead, runs during HTML streaming |
+| **Permutive** | Low | SDK cached (1 hour default) | API calls proxied in real-time |
+| **Sourcepoint** | Low | CDN cached (1 hour default) | JS rewriting adds minor overhead |
+| **Testlight** | Low | No caching | Development use only |
## Environment Variables
@@ -230,6 +269,10 @@ TRUSTED_SERVER__INTEGRATIONS__NEXTJS__ENABLED=true
TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__ORGANIZATION_ID="neworg"
TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__WORKSPACE_ID="workspace-123"
+# Sourcepoint
+TRUSTED_SERVER__INTEGRATIONS__SOURCEPOINT__ENABLED=true
+TRUSTED_SERVER__INTEGRATIONS__SOURCEPOINT__CDN_ORIGIN="https://cdn.privacy-mgmt.com"
+
# Testlight
TRUSTED_SERVER__INTEGRATIONS__TESTLIGHT__ENDPOINT="https://test.example.com"
```
diff --git a/docs/guide/integrations/sourcepoint.md b/docs/guide/integrations/sourcepoint.md
new file mode 100644
index 00000000..5abc8626
--- /dev/null
+++ b/docs/guide/integrations/sourcepoint.md
@@ -0,0 +1,74 @@
+# Sourcepoint Integration
+
+Sourcepoint provides consent and privacy messaging for publishers. This integration proxies the Sourcepoint CDN endpoint through Trusted Server so the browser loads it from a first-party path.
+
+## Overview
+
+The Sourcepoint integration:
+
+- Proxies `cdn.privacy-mgmt.com` requests through `/integrations/sourcepoint/cdn/*`
+- Rewrites matching `src` and `href` attributes during HTML processing
+- Rewrites JavaScript response bodies so webpack chunks and API calls route through the proxy
+- Injects a `window._sp_` property trap for config URLs set by Next.js hydration chunks
+- Installs a client-side script guard for dynamically inserted Sourcepoint assets
+
+## Configuration
+
+Add the following to `trusted-server.toml`:
+
+```toml
+[integrations.sourcepoint]
+enabled = true
+rewrite_sdk = true
+cdn_origin = "https://cdn.privacy-mgmt.com"
+# Optional: forward a custom Sourcepoint authCookie name upstream.
+# auth_cookie_name = "sp_auth"
+cache_ttl_seconds = 3600
+```
+
+### Configuration Options
+
+| Option | Type | Default | Description |
+| ------------------- | ---------------- | ------------------------------ | -------------------------------------------------------------------------------------------- |
+| `enabled` | boolean | `false` | Enable the Sourcepoint integration |
+| `rewrite_sdk` | boolean | `true` | Rewrite matching Sourcepoint URLs in HTML |
+| `cdn_origin` | string | `https://cdn.privacy-mgmt.com` | Sourcepoint CDN origin |
+| `auth_cookie_name` | string or `null` | `null` | Optional custom Sourcepoint `authCookie` name to forward upstream alongside built-in cookies |
+| `cache_ttl_seconds` | integer | `3600` | Cache TTL applied to successful CDN responses when the origin omits cache headers |
+
+## Endpoints
+
+| Method | Path | Description |
+| ---------- | --------------------------------- | --------------------------------------------- |
+| `GET/POST` | `/integrations/sourcepoint/cdn/*` | Proxy Sourcepoint CDN assets and wrapper APIs |
+
+## HTML Rewriting
+
+When `rewrite_sdk = true`, Trusted Server rewrites matching Sourcepoint URLs in HTML responses:
+
+```html
+
+
+
+
+
+```
+
+## Client-Side Guard
+
+Single-page apps often insert CMP scripts after the initial HTML response. The `sourcepoint` tsjs module installs a DOM insertion guard so dynamically inserted Sourcepoint script and preload URLs are rewritten to first-party paths before the browser fetches them.
+
+## Cookie Forwarding and Caching
+
+Trusted Server forwards only Sourcepoint's documented cookie names upstream, plus the optional `auth_cookie_name` when configured. Unrelated publisher cookies are deliberately excluded so first-party application state is not leaked to Sourcepoint.
+
+Responses that include `Set-Cookie` are forced to `Cache-Control: private, no-store` so cookie-bearing Sourcepoint traffic is never marked as publicly cacheable content by the proxy.
+
+## Notes
+
+- This version scopes the integration to `cdn.privacy-mgmt.com`. Additional Sourcepoint domains (e.g., `geo.privacymanager.io`) can be added later if publishers require them.
+
+## See Also
+
+- [Integration Guide](/guide/integration-guide)
+- [Integrations Overview](/guide/integrations-overview)
diff --git a/trusted-server.toml b/trusted-server.toml
index d9189aaa..9adc9c33 100644
--- a/trusted-server.toml
+++ b/trusted-server.toml
@@ -69,6 +69,14 @@ enabled = false
sdk_origin = "https://sdk.privacy-center.org"
api_origin = "https://api.privacy-center.org"
+[integrations.sourcepoint]
+enabled = false
+rewrite_sdk = true
+cdn_origin = "https://cdn.privacy-mgmt.com"
+# Optional: forward a custom Sourcepoint authCookie name upstream.
+# auth_cookie_name = "sp_auth"
+cache_ttl_seconds = 3600
+
[integrations.permutive]
enabled = false
organization_id = ""
@@ -190,4 +198,3 @@ timeout_ms = 1000
[integrations.adserver_mock.context_query_params]
permutive_segments = "permutive"
-