diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index a491eb6e..9c95f8bb 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -80,7 +80,7 @@ runs: env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret + TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1dd8f032..2da273aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080 TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret + TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/crates/integration-tests/fixtures/configs/viceroy-template.toml b/crates/integration-tests/fixtures/configs/viceroy-template.toml index b7109b12..086e3e4f 100644 --- a/crates/integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/integration-tests/fixtures/configs/viceroy-template.toml @@ -25,11 +25,29 @@ key = "placeholder" data = "placeholder" - # Pre-seeded EC row for KV-backed EC lifecycle tests. + # Pre-seeded EC rows for KV-backed EC lifecycle tests. Each scenario + # uses a separate row so withdrawal tombstones do not leak across + # sequential scenario execution in the same Viceroy instance. [[local_server.kv_stores.ec_identity_store]] key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01" data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + [[local_server.kv_stores.ec_identity_store]] + key = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.test02" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.test03" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.test04" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.test05" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + [[local_server.kv_stores.ec_partner_store]] key = "placeholder" data = "placeholder" diff --git a/crates/integration-tests/tests/frameworks/scenarios.rs b/crates/integration-tests/tests/frameworks/scenarios.rs index 558dfc50..8fc112fa 100644 --- a/crates/integration-tests/tests/frameworks/scenarios.rs +++ b/crates/integration-tests/tests/frameworks/scenarios.rs @@ -500,16 +500,17 @@ impl EcScenario { /// US Privacy signal that explicitly allows storage in the default Viceroy /// integration-test geo (US-CA). const ALLOW_US_PRIVACY_COOKIE: &str = "1YNN"; -const SEEDED_EC_ID: &str = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01"; - fn allow_ec_generation(client: &EcTestClient) { client.set_cookie("us_privacy", ALLOW_US_PRIVACY_COOKIE); } -fn use_seeded_ec(client: &EcTestClient) -> String { - client.set_cookie("ts-ec", SEEDED_EC_ID); - normalize_ec_id(SEEDED_EC_ID) +fn seeded_ec_id(hex_digit: char, suffix: &str) -> String { + format!("{}.{suffix}", hex_digit.to_string().repeat(64)) +} + +fn use_seeded_ec(client: &EcTestClient, ec_id: &str) -> String { + client.set_cookie("ts-ec", ec_id); + normalize_ec_id(ec_id) } /// Full lifecycle: seeded EC → batch sync → identify (Bearer auth) with scoped UID. @@ -518,7 +519,8 @@ fn use_seeded_ec(client: &EcTestClient) -> String { fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('a', "test01"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC full lifecycle: using seeded EC ID = {ec_id}"); // 2. Batch sync writes partner UID (partner "inttest" is in config) @@ -576,7 +578,8 @@ fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('b', "test02"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC consent withdrawal: using seeded EC = {ec_id}"); // GPC overrides the allow cookie in US-CA, so this is an explicit @@ -623,7 +626,8 @@ fn ec_identify_without_ec(base_url: &str) -> TestResult<()> { fn ec_identify_consent_denied(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let _ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('c', "test03"); + let _ec_id = use_seeded_ec(&client, &seeded_ec_id); // Identify with GPC=1 — in the default US-CA test geo, GPC is an explicit // denial that must override the allow cookie. Per spec §11.4, consent is @@ -647,7 +651,8 @@ fn ec_identify_consent_denied(base_url: &str) -> TestResult<()> { fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('d', "test04"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC concurrent syncs: using seeded EC = {ec_id}"); // Batch sync both partners (both are pre-configured in trusted-server.toml) @@ -705,7 +710,8 @@ fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> { fn ec_batch_sync_happy_path(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('e', "test05"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC batch sync happy path: using seeded ec_id = {ec_id}"); // Batch sync writes a UID for this EC ID (partner "inttest" is in config) diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 6b03e820..fbc798c2 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -451,6 +451,8 @@ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { function syncPrebidEidsCookie(): void { try { if (typeof pbjs.getUserIdsAsEids !== 'function') { + // Without Prebid EIDs to forward, stale auction fallback IDs must not persist. + clearPrebidEidsCookie(); return; } 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..f8413134 --- /dev/null +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -0,0 +1,181 @@ +import { log } from '../../core/log'; + +const SP_CONSENT_PREFIX = '_sp_user_consent_'; +const GPP_COOKIE_NAME = '__gpp'; +const GPP_SID_COOKIE_NAME = '__gpp_sid'; +const GPP_SOURCE_COOKIE_NAME = '_ts_gpp_src'; +const GPP_SOURCE_SOURCEPOINT = 'sp'; +const INITIAL_RETRY_DELAY_MS = 500; + +interface SourcepointGppData { + gppString: string; + applicableSections: number[]; +} + +interface SourcepointConsentPayload { + gppData?: SourcepointGppData; +} + +let initialized = false; +let initialRetryDone = false; +let retryTimer: ReturnType | undefined; + +function findSourcepointConsent(): SourcepointConsentPayload | null { + // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. + // We intentionally take the first valid match and mirror that origin-scoped payload. + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; + + const raw = localStorage.getItem(key); + if (!raw) continue; + + try { + const payload = JSON.parse(raw) as SourcepointConsentPayload; + if (payload.gppData?.gppString) { + return payload; + } + } catch { + log.debug('sourcepoint: failed to parse localStorage value', { key }); + } + } + return null; +} + +function readCookie(name: string): string | undefined { + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + return cookie?.slice(prefix.length); +} + +function hasSourcepointMarker(): boolean { + return readCookie(GPP_SOURCE_COOKIE_NAME) === GPP_SOURCE_SOURCEPOINT; +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax`; +} + +function clearCookie(name: string): void { + document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; +} + +function clearSourcepointCookies(): void { + if (!hasSourcepointMarker()) { + return; + } + + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + clearCookie(GPP_SOURCE_COOKIE_NAME); +} + +function mirrorOnVisible(): void { + if (document.visibilityState === 'visible') { + mirrorSourcepointConsent(); + } +} + +function clearInitialRetryTimer(): void { + if (retryTimer === undefined) { + return; + } + + window.clearTimeout(retryTimer); + retryTimer = undefined; +} + +function scheduleInitialRetry(): void { + if (initialRetryDone || retryTimer !== undefined) { + return; + } + + const retry = (): void => { + if (initialRetryDone) { + return; + } + + initialRetryDone = true; + clearInitialRetryTimer(); + mirrorSourcepointConsent(); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', retry, { once: true }); + } + + retryTimer = window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); +} + +/** + * Reads Sourcepoint consent from localStorage and mirrors it into + * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. + * + * Returns `true` if cookies were written, `false` otherwise. + */ +export function mirrorSourcepointConsent(): boolean { + if (typeof localStorage === 'undefined' || typeof document === 'undefined') { + return false; + } + + const payload = findSourcepointConsent(); + if (!payload?.gppData) { + clearSourcepointCookies(); + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + + const { gppString, applicableSections } = payload.gppData; + if (!gppString) { + clearSourcepointCookies(); + log.debug('sourcepoint: gppString is empty'); + return false; + } + + const existingGppCookie = readCookie(GPP_COOKIE_NAME); + if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { + log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); + return false; + } + + writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); + writeCookie(GPP_COOKIE_NAME, gppString); + + if (Array.isArray(applicableSections) && applicableSections.length > 0) { + writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); + } else { + clearCookie(GPP_SID_COOKIE_NAME); + } + + initialRetryDone = true; + clearInitialRetryTimer(); + + log.info('sourcepoint: mirrored GPP consent to cookies', { + gppLength: gppString.length, + sections: applicableSections, + }); + + return true; +} + +/** + * Initializes Sourcepoint consent mirroring and bounded refresh hooks. + */ +export function initializeSourcepointConsentMirror(): void { + if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + initialized = true; + + if (!mirrorSourcepointConsent()) { + scheduleInitialRetry(); + } + + // Sourcepoint persists consent changes to localStorage. Re-mirror when a + // user returns to the page so session cookies do not remain stale. + document.addEventListener('visibilitychange', mirrorOnVisible); + window.addEventListener('focus', mirrorSourcepointConsent); +} + +initializeSourcepointConsentMirror(); diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index e7cfd3a7..47034861 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -6,9 +6,9 @@ const { mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, + mockGetUserIdsAsEids, mockPbjs, mockGetBidAdapter, - mockGetUserIdsAsEids, mockAdapterManager, } = vi.hoisted(() => { const mockSetConfig = vi.fn(); @@ -16,7 +16,9 @@ const { const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); const mockGetBidAdapter = vi.fn(); - const mockGetUserIdsAsEids = vi.fn(); + const mockGetUserIdsAsEids = vi.fn( + () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> + ); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, @@ -33,9 +35,9 @@ const { mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, + mockGetUserIdsAsEids, mockPbjs, mockGetBidAdapter, - mockGetUserIdsAsEids, mockAdapterManager, }; }); 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..21dbc178 --- /dev/null +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; + +const SOURCEPOINT_MARKER_COOKIE = '_ts_gpp_src'; + +function sourcepointPayload(gppString = 'DBABLA~BVQqAAAAAgA.QA', applicableSections = [7]) { + return { + gppData: { + gppString, + applicableSections, + }, + }; +} + +describe('integrations/sourcepoint', () => { + function clearAllCookies(): void { + document.cookie.split(';').forEach((c) => { + const name = c.split('=')[0].trim(); + if (name) document.cookie = `${name}=; path=/; Max-Age=0`; + }); + } + + function getCookie(name: string): string | undefined { + const match = document.cookie.split('; ').find((c) => c.startsWith(`${name}=`)); + return match ? match.split('=').slice(1).join('=') : undefined; + } + + beforeEach(() => { + // Clear cookies and localStorage before each test. + clearAllCookies(); + localStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); + clearAllCookies(); + localStorage.clear(); + }); + + it('mirrors __gpp and __gpp_sid from _sp_user_consent_* localStorage as session cookies', () => { + localStorage.setItem('_sp_user_consent_36026', JSON.stringify(sourcepointPayload())); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(document.cookie).toContain('__gpp=DBABLA~BVQqAAAAAgA.QA'); + expect(document.cookie).toContain('__gpp_sid=7'); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); + }); + + it('handles multiple applicable sections', () => { + localStorage.setItem( + '_sp_user_consent_99999', + JSON.stringify(sourcepointPayload('DBABLA~BVQqAAAAAgA.QA', [7, 8])) + ); + + mirrorSourcepointConsent(); + + expect(document.cookie).toContain('__gpp_sid=7,8'); + }); + + it('returns false when no _sp_user_consent_* key exists', () => { + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + expect(document.cookie).not.toContain('__gpp_sid='); + }); + + it('does not clear non-Sourcepoint GPP cookies when no valid Sourcepoint payload exists', () => { + document.cookie = '__gpp=other-cmp-gpp; path=/'; + document.cookie = '__gpp_sid=7,8; path=/'; + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(getCookie('__gpp')).toBe('other-cmp-gpp'); + expect(getCookie('__gpp_sid')).toBe('7,8'); + }); + + it('does not overwrite GPP cookies owned by another CMP', () => { + document.cookie = '__gpp=other-cmp-gpp; path=/'; + document.cookie = '__gpp_sid=2; path=/'; + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('sourcepoint-gpp', [7])) + ); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(getCookie('__gpp')).toBe('other-cmp-gpp'); + expect(getCookie('__gpp_sid')).toBe('2'); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBeUndefined(); + }); + + it('clears stale Sourcepoint-owned mirrored cookies when no valid Sourcepoint payload exists', () => { + document.cookie = '__gpp=stale-gpp; path=/'; + document.cookie = '__gpp_sid=7,8; path=/'; + document.cookie = `${SOURCEPOINT_MARKER_COOKIE}=sp; path=/`; + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(getCookie('__gpp')).toBeUndefined(); + expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBeUndefined(); + }); + + it('returns false for malformed JSON in localStorage', () => { + localStorage.setItem('_sp_user_consent_12345', 'not-json!!!'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('skips malformed entries when a later Sourcepoint key is valid', () => { + localStorage.setItem('_sp_user_consent_12345', 'not-json!!!'); + localStorage.setItem('_sp_user_consent_67890', JSON.stringify(sourcepointPayload())); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); + expect(getCookie('__gpp_sid')).toBe('7'); + }); + + it('returns false when gppData is missing from payload', () => { + localStorage.setItem('_sp_user_consent_12345', JSON.stringify({ otherField: true })); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('returns false when gppString is empty', () => { + localStorage.setItem('_sp_user_consent_12345', JSON.stringify(sourcepointPayload('', [7]))); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('clears stale __gpp_sid when the payload has no applicable sections', () => { + document.cookie = '__gpp_sid=7,8; path=/'; + document.cookie = `${SOURCEPOINT_MARKER_COOKIE}=sp; path=/`; + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('DBABLA~BVQqAAAAAgA.QA', [])) + ); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); + expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); + }); + + it('updates GPP cookies when Sourcepoint owns the marker', () => { + document.cookie = '__gpp=stale-sourcepoint-gpp; path=/'; + document.cookie = '__gpp_sid=7; path=/'; + document.cookie = `${SOURCEPOINT_MARKER_COOKIE}=sp; path=/`; + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('updated-sourcepoint-gpp', [8])) + ); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('updated-sourcepoint-gpp'); + expect(getCookie('__gpp_sid')).toBe('8'); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); + }); + + it('refreshes mirrored cookies when the window regains focus', () => { + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('initial-gpp', [7])) + ); + + mirrorSourcepointConsent(); + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('updated-gpp', [8])) + ); + window.dispatchEvent(new Event('focus')); + + expect(getCookie('__gpp')).toBe('updated-gpp'); + expect(getCookie('__gpp_sid')).toBe('8'); + }); + + it('clears Sourcepoint-owned cookies when consent is retracted before focus', () => { + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('initial-gpp', [7])) + ); + + mirrorSourcepointConsent(); + localStorage.removeItem('_sp_user_consent_12345'); + window.dispatchEvent(new Event('focus')); + + expect(getCookie('__gpp')).toBeUndefined(); + expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBeUndefined(); + }); + + it('retries once after module initialization when Sourcepoint data appears shortly after load', async () => { + vi.useFakeTimers(); + vi.resetModules(); + localStorage.clear(); + clearAllCookies(); + + await import('../../../src/integrations/sourcepoint'); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('retry-gpp', [7])) + ); + vi.advanceTimersByTime(500); + + expect(getCookie('__gpp')).toBe('retry-gpp'); + expect(getCookie('__gpp_sid')).toBe('7'); + }); + + it('clears a pending initial retry after a successful manual mirror', async () => { + vi.useFakeTimers(); + vi.resetModules(); + localStorage.clear(); + clearAllCookies(); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + + const sourcepoint = await import('../../../src/integrations/sourcepoint'); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('manual-gpp', [7])) + ); + expect(sourcepoint.mirrorSourcepointConsent()).toBe(true); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('timer-gpp', [8])) + ); + document.dispatchEvent(new Event('DOMContentLoaded')); + vi.advanceTimersByTime(500); + + expect(getCookie('__gpp')).toBe('manual-gpp'); + expect(getCookie('__gpp_sid')).toBe('7'); + }); + + it('does not run both DOMContentLoaded and timer retries', async () => { + vi.useFakeTimers(); + vi.resetModules(); + localStorage.clear(); + clearAllCookies(); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + + await import('../../../src/integrations/sourcepoint'); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('domcontentloaded-gpp', [7])) + ); + document.dispatchEvent(new Event('DOMContentLoaded')); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('timer-gpp', [8])) + ); + vi.advanceTimersByTime(500); + + expect(getCookie('__gpp')).toBe('domcontentloaded-gpp'); + expect(getCookie('__gpp_sid')).toBe('7'); + }); +}); diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index 9d0e5c81..ffb770c2 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -71,11 +71,14 @@ pub fn decode_gpp_string(gpp_string: &str) -> Result Option { } } +/// GPP section IDs that represent US state/national privacy sections. +/// +/// Range 7–23 per the GPP v1 specification: +/// 7=UsNat, 8=UsCa, 9=UsVa, 10=UsCo, 11=UsUt, 12=UsCt, 13=UsFl, +/// 14=UsMt, 15=UsOr, 16=UsTx, 17=UsDe, 18=UsIa, 19=UsNe, 20=UsNh, +/// 21=UsNj, 22=UsTn, 23=UsMn. +const US_SECTION_ID_RANGE: std::ops::RangeInclusive = 7..=23; + +/// Extracts the `sale_opt_out` signal across all US sections in a parsed GPP +/// string. +/// +/// Iterates through section IDs looking for any in the US range (7–23), +/// decodes each US section, and aggregates the result conservatively: +/// +/// - `Some(true)` if any decodable US section says the user opted out of sale +/// - `Some(false)` if at least one decodable US section says they did not opt +/// out and none say they opted out +/// - `None` if no US section is present or no decodable US section yields a +/// usable `sale_opt_out` signal +fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { + let mut result = None; + + for us_section_id in parsed + .section_ids() + .filter(|id| US_SECTION_ID_RANGE.contains(&(**id as u16))) + { + match parsed.decode_section(*us_section_id) { + Ok(section) => match us_sale_opt_out_from_section(§ion) { + Some(true) => return Some(true), + Some(false) => result = Some(false), + None => {} + }, + Err(e) => { + log::warn!("Failed to decode US GPP section {us_section_id}: {e}"); + } + } + } + + result +} + +fn us_sale_opt_out_from_section(section: &iab_gpp::sections::Section) -> Option { + use iab_gpp::sections::us_common::OptOut; + use iab_gpp::sections::Section; + + // Keep this match in sync with new US-state variants added by `iab_gpp`. + let sale_opt_out = match section { + Section::UsNat(s) => match &s.core { + iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, + iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out, + _ => return None, + }, + Section::UsCa(s) => &s.core.sale_opt_out, + Section::UsVa(s) => &s.core.sale_opt_out, + Section::UsCo(s) => &s.core.sale_opt_out, + Section::UsUt(s) => &s.core.sale_opt_out, + Section::UsCt(s) => &s.core.sale_opt_out, + Section::UsFl(s) => &s.core.sale_opt_out, + Section::UsMt(s) => &s.core.sale_opt_out, + Section::UsOr(s) => &s.core.sale_opt_out, + Section::UsTx(s) => &s.core.sale_opt_out, + Section::UsDe(s) => &s.core.sale_opt_out, + Section::UsIa(s) => &s.core.sale_opt_out, + Section::UsNe(s) => &s.core.sale_opt_out, + Section::UsNh(s) => &s.core.sale_opt_out, + Section::UsNj(s) => &s.core.sale_opt_out, + Section::UsTn(s) => &s.core.sale_opt_out, + Section::UsMn(s) => &s.core.sale_opt_out, + _ => return None, + }; + + Some(*sale_opt_out == OptOut::OptedOut) +} + /// Parses a `__gpp_sid` cookie value into a vector of section IDs. /// /// The cookie is a comma-separated list of integer section IDs, e.g. `"2,6"`. @@ -239,4 +316,154 @@ mod tests { "all-invalid should be None" ); } + + #[test] + fn decodes_us_sale_opt_out_not_opted_out() { + let result = decode_gpp_string("DBABLA~BVQqAAAAAgA.QA"); + match &result { + Ok(gpp) => { + assert_eq!( + gpp.us_sale_opt_out, + Some(false), + "should extract sale_opt_out=false from UsNat section" + ); + } + Err(e) => { + panic!("GPP decode failed: {e}"); + } + } + } + + fn encode_fibonacci_integer(mut value: u16) -> String { + let mut fibs = vec![1_u16]; + let mut next = 2_u16; + while next <= value { + fibs.push(next); + next = if fibs.len() == 1 { + 2 + } else { + fibs[fibs.len() - 1] + fibs[fibs.len() - 2] + }; + } + + let mut bits = vec![false; fibs.len()]; + for (idx, fib) in fibs.iter().enumerate().rev() { + if *fib <= value { + value -= *fib; + bits[idx] = true; + } + } + bits.push(true); + + bits.into_iter() + .map(|bit| if bit { '1' } else { '0' }) + .collect() + } + + fn encode_header(section_ids: &[u16]) -> String { + const BASE64_URL: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + let mut bits = String::from("000011000001"); + bits.push_str(&format!("{:012b}", section_ids.len())); + + let mut previous = 0_u16; + for §ion_id in section_ids { + bits.push('0'); + bits.push_str(&encode_fibonacci_integer(section_id - previous)); + previous = section_id; + } + + while bits.len() % 6 != 0 { + bits.push('0'); + } + + bits.as_bytes() + .chunks(6) + .map(|chunk| { + let value = u8::from_str_radix( + core::str::from_utf8(chunk).expect("should encode header bits as utf8"), + 2, + ) + .expect("should parse 6-bit chunk"); + char::from(BASE64_URL[value as usize]) + }) + .collect() + } + + fn gpp_with_sections(sections: &[(u16, &str)]) -> String { + let ids = sections.iter().map(|(id, _)| *id).collect::>(); + let header = encode_header(&ids); + let section_payloads = sections.iter().map(|(_, raw)| *raw).collect::>(); + format!("{header}~{}", section_payloads.join("~")) + } + + #[test] + fn no_us_section_returns_none() { + let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode GPP"); + assert_eq!( + result.us_sale_opt_out, None, + "should return None when no US section (7-23) is present" + ); + } + + #[test] + fn later_us_section_opt_out_overrides_earlier_non_opt_out() { + let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVVVVVVVVWA.AA")]); + + let result = decode_gpp_string(&gpp).expect("should decode multi-section US GPP"); + + assert_eq!( + result.us_sale_opt_out, + Some(true), + "should treat any later decodable opt-out as authoritative" + ); + } + + #[test] + fn multiple_us_sections_without_opt_out_return_false() { + let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVgVVVVVVWA.AA")]); + + let result = decode_gpp_string(&gpp).expect("should decode multi-section US GPP"); + + assert_eq!( + result.us_sale_opt_out, + Some(false), + "should return false when decodable US sections consistently do not opt out" + ); + } + + #[test] + fn valid_opt_out_wins_even_if_another_us_section_is_undecodable() { + let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "not-a-valid-usva-section")]); + + let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections"); + + assert_eq!( + result.us_sale_opt_out, + Some(false), + "should keep a valid non-opt-out signal even when another US section fails to decode" + ); + + let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "BVVVVVVVVWA.AA")]); + let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections"); + + assert_eq!( + result.us_sale_opt_out, + Some(true), + "should let a valid opt-out win even when another US section fails to decode" + ); + } + + #[test] + fn only_undecodable_us_sections_return_none() { + let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "also-invalid")]); + + let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections"); + + assert_eq!( + result.us_sale_opt_out, None, + "should return None when no decodable US section yields sale_opt_out" + ); + } } diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 013d7e51..6dff4121 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -489,9 +489,16 @@ pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { } // When a CMP uses TCF in the US (e.g. Didomi), respect the // TCF Purpose 1 decision — this is an explicit opt-in signal. + // The Sourcepoint GPP design documents this precedence decision. if let Some(tcf) = effective_tcf(ctx) { return tcf.has_storage_consent(); } + // Check GPP US section for sale opt-out. + if let Some(gpp) = &ctx.gpp { + if let Some(opted_out) = gpp.us_sale_opt_out { + return !opted_out; + } + } // Check US Privacy string for explicit opt-out. if let Some(usp) = &ctx.us_privacy { return usp.opt_out_sale != PrivacyFlag::Yes; @@ -686,6 +693,7 @@ mod tests { version: 1, section_ids: vec![2], eu_tcf: Some(make_tcf(gpp_last_updated_ds, gpp_allows_eids)), + us_sale_opt_out: None, }), ..ConsentContext::default() } @@ -818,6 +826,7 @@ mod tests { version: 1, section_ids: vec![2], eu_tcf: Some(make_tcf(0, true)), + us_sale_opt_out: None, }), ..ConsentContext::default() }; @@ -900,6 +909,7 @@ mod tests { version: 1, section_ids: vec![2], eu_tcf: Some(make_tcf_with_storage(true)), + us_sale_opt_out: None, }), gdpr_applies: true, ..ConsentContext::default() @@ -1101,4 +1111,126 @@ mod tests { "TCF consent should take priority over US Privacy opt-out when both present" ); } + + #[test] + fn ec_allowed_us_state_gpp_no_sale_opt_out() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "US state + GPP US sale_opt_out=false should allow EC" + ); + } + + #[test] + fn ec_blocked_us_state_gpp_sale_opted_out() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + !allows_ec_creation(&ctx), + "US state + GPP US sale_opt_out=true should block EC" + ); + } + + #[test] + fn ec_blocked_us_state_gpc_overrides_gpp_us() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpc: true, + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + ..ConsentContext::default() + }; + assert!( + !allows_ec_creation(&ctx), + "GPC should block EC even when GPP US says no opt-out" + ); + } + + #[test] + fn ec_us_state_tcf_takes_priority_over_gpp_us() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + tcf: Some(make_tcf_with_storage(true)), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "TCF consent should take priority over GPP US opt-out" + ); + } + + #[test] + fn ec_us_state_gpp_us_takes_priority_over_us_privacy() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "GPP US should take priority over us_privacy opt-out" + ); + } + + #[test] + fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: None, + us_sale_opt_out: None, + }), + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::No, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "GPP without US section should fall through to us_privacy" + ); + } } diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs index a68eda9a..44f1a3df 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -302,6 +302,13 @@ pub struct GppConsent { pub section_ids: Vec, /// Decoded EU TCF v2.2 section (if present in GPP, section ID 2). pub eu_tcf: Option, + /// Whether the user opted out of sale of personal information via a US GPP + /// section (IDs 7–23). + /// + /// - `Some(true)` — a US section is present and `sale_opt_out == OptedOut` + /// - `Some(false)` — a US section is present and user did not opt out + /// - `None` — no US section exists in the GPP string + pub us_sale_opt_out: Option, } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index b91425b4..0a93e6bc 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -795,14 +795,15 @@ impl IntegrationRegistry { /// Return JS module IDs that should be included in the tsjs bundle. /// - /// Always includes "creative" (JS-only, no Rust-side registration). + /// Always includes JS-only modules with no Rust-side registration. /// Excludes integrations that have no JS module (e.g., "nextjs"). #[must_use] pub fn js_module_ids(&self) -> Vec<&'static str> { // Rust-only integrations with no corresponding JS module const JS_EXCLUDED: &[&str] = &["nextjs", "aps", "adserver_mock"]; - // JS-only modules always included (no Rust-side registration) - const JS_ALWAYS: &[&str] = &["creative"]; + // JS-only modules always included (no Rust-side registration). + // Sourcepoint's JS guards cookie clearing with a Sourcepoint-owned marker. + const JS_ALWAYS: &[&str] = &["creative", "sourcepoint"]; let mut ids: Vec<&'static str> = JS_ALWAYS.to_vec(); @@ -1416,7 +1417,7 @@ mod tests { } #[test] - fn js_module_ids_immediate_excludes_prebid() { + fn js_module_ids_immediate_excludes_prebid_and_includes_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1444,6 +1445,14 @@ mod tests { all.contains(&"prebid"), "should include prebid in full list" ); + assert!( + immediate.contains(&"creative"), + "should include creative in immediate IDs" + ); + assert!( + immediate.contains(&"sourcepoint"), + "should include sourcepoint in immediate IDs" + ); assert!( !immediate.contains(&"prebid"), "should not include prebid in immediate IDs" diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index f7b7a910..9a9c24e7 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -223,6 +223,16 @@ The build script (`build-all.mjs`) validates that each adapter exists in `prebid Adding a new client-side bidder requires both a config change (`client_side_bidders`) **and** a rebuild with the adapter included in `TSJS_PREBID_ADAPTERS`. Without the adapter in the bundle, the bidder is silently dropped from both server-side and client-side auctions. ::: +## User ID Modules + +Prebid.js can expose publisher-configured User ID Module output via +`pbjs.getUserIdsAsEids()`. The TSJS Prebid shim reads those current-request +EIDs after auctions and forwards them to Trusted Server when they are available. + +Build-time configurable User ID submodule selection is not currently part of the +TSJS build pipeline. Do not rely on a `TSJS_PREBID_USER_IDS` environment +variable or generated `_user_ids.generated.ts` file for slim User ID builds. + ## Identity Forwarding Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions: diff --git a/docs/superpowers/plans/2026-04-15-sourcepoint-gpp-consent.md b/docs/superpowers/plans/2026-04-15-sourcepoint-gpp-consent.md new file mode 100644 index 00000000..8c9de843 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-sourcepoint-gpp-consent.md @@ -0,0 +1,695 @@ +# Sourcepoint GPP Consent for Edge Cookie Generation — 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:** Enable EC generation for sites using Sourcepoint by mirroring localStorage consent into cookies (client) and recognizing GPP US `sale_opt_out` as a consent signal (server). + +**Architecture:** New JS-only `sourcepoint` integration auto-discovers `_sp_user_consent_*` in localStorage and writes `__gpp` / `__gpp_sid` cookies. Server-side, `GppConsent` gains a `us_sale_opt_out: Option` field extracted from any GPP US section (IDs 7–23). `allows_ec_creation()` checks this field between the existing TCF and `us_privacy` branches. + +**Tech Stack:** TypeScript (Vitest, jsdom), Rust (iab_gpp crate for GPP section decoding) + +**Spec:** `docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `crates/trusted-server-core/src/consent/types.rs` | Modify | Add `us_sale_opt_out: Option` to `GppConsent` | +| `crates/trusted-server-core/src/consent/gpp.rs` | Modify | Decode US sections, extract `sale_opt_out` | +| `crates/trusted-server-core/src/consent/mod.rs` | Modify | Add GPP US branch in `allows_ec_creation()`, tests | +| `crates/js/lib/src/integrations/sourcepoint/index.ts` | Create | localStorage auto-discovery, cookie mirroring | +| `crates/js/lib/test/integrations/sourcepoint/index.test.ts` | Create | Vitest tests for cookie mirroring | + +--- + +## Task 1: Add `us_sale_opt_out` field to `GppConsent` + +**Files:** +- Modify: `crates/trusted-server-core/src/consent/types.rs:297-305` + +- [ ] **Step 1: Add the field** + +In `crates/trusted-server-core/src/consent/types.rs`, add `us_sale_opt_out` to `GppConsent`: + +```rust +/// Decoded GPP (Global Privacy Platform) consent data. +/// +/// Wraps the `iab_gpp` crate's decoded output with our domain types. +#[derive(Debug, Clone)] +pub struct GppConsent { + /// GPP header version. + pub version: u8, + /// Active section IDs present in the GPP string. + pub section_ids: Vec, + /// Decoded EU TCF v2.2 section (if present in GPP, section ID 2). + pub eu_tcf: Option, + /// Whether the user opted out of sale of personal information via a US GPP + /// section (IDs 7–23). + /// + /// - `Some(true)` — a US section is present and `sale_opt_out == OptedOut` + /// - `Some(false)` — a US section is present and user did not opt out + /// - `None` — no US section exists in the GPP string + pub us_sale_opt_out: Option, +} +``` + +- [ ] **Step 2: Fix compilation — update all `GppConsent` construction sites** + +There are existing places that construct `GppConsent`. Each needs the new field. Search for them: + +In `crates/trusted-server-core/src/consent/gpp.rs` (~line 74), update `decode_gpp_string`: + +```rust + Ok(GppConsent { + version: 1, + section_ids, + eu_tcf, + us_sale_opt_out: None, // placeholder — Task 2 fills this in + }) +``` + +In `crates/trusted-server-core/src/consent/mod.rs`, find every test that constructs `GppConsent` (search for `GppConsent {`). Add `us_sale_opt_out: None` to each. There are instances around lines 720, 883, and 965: + +```rust + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(...), + us_sale_opt_out: None, + }), +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cargo check --workspace` +Expected: compiles with no errors. + +- [ ] **Step 4: Run tests to confirm nothing broke** + +Run: `cargo test --workspace` +Expected: all existing tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/consent/types.rs \ + crates/trusted-server-core/src/consent/gpp.rs \ + crates/trusted-server-core/src/consent/mod.rs +git commit -m "Add us_sale_opt_out field to GppConsent" +``` + +--- + +## Task 2: Decode US sale opt-out from GPP sections + +**Files:** +- Modify: `crates/trusted-server-core/src/consent/gpp.rs` + +- [ ] **Step 1: Write the failing test for US sale opt-out extraction** + +Add to the `#[cfg(test)] mod tests` block in `crates/trusted-server-core/src/consent/gpp.rs`: + +```rust + // A GPP string with UsNat section (section ID 7). + // Header "DBABLA" encodes: version=1, section IDs=[7] (UsNat). + // The section string encodes a UsNat v1 core with sale_opt_out=DidNotOptOut (2). + #[test] + fn decodes_us_sale_opt_out_not_opted_out() { + // Build a real GPP string with UsNat section using iab_gpp parsing. + // "DBABLA~BVQqAAAAAgA.QA" is the example from the issue (Sourcepoint payload). + let result = decode_gpp_string("DBABLA~BVQqAAAAAgA.QA"); + match &result { + Ok(gpp) => { + assert_eq!( + gpp.us_sale_opt_out, + Some(false), + "should extract sale_opt_out=false from UsNat section" + ); + } + Err(e) => { + // If the specific GPP string doesn't parse, test with section ID presence. + // The important thing is that the decode_us_sale_opt_out function is wired up. + panic!("GPP decode failed: {e}"); + } + } + } + + #[test] + fn no_us_section_returns_none() { + // GPP_TCF_AND_USP has section IDs [2, 6] — no US sections (7–23). + let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode GPP"); + assert_eq!( + result.us_sale_opt_out, None, + "should return None when no US section (7-23) is present" + ); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p trusted-server-core -- consent::gpp::tests::decodes_us_sale_opt_out` +Expected: FAIL — `us_sale_opt_out` is hardcoded to `None`. + +- [ ] **Step 3: Implement `decode_us_sale_opt_out`** + +In `crates/trusted-server-core/src/consent/gpp.rs`, add after `decode_tcf_from_gpp`: + +```rust +/// GPP section IDs that represent US state/national privacy sections. +/// +/// Range 7–23 per the GPP v1 specification: +/// 7=UsNat, 8=UsCa, 9=UsVa, 10=UsCo, 11=UsUt, 12=UsCt, 13=UsFl, +/// 14=UsMt, 15=UsOr, 16=UsTx, 17=UsDe, 18=UsIa, 19=UsNe, 20=UsNh, +/// 21=UsNj, 22=UsTn, 23=UsMn. +const US_SECTION_ID_RANGE: std::ops::RangeInclusive = 7..=23; + +/// Extracts the `sale_opt_out` signal from the first US section in a parsed +/// GPP string. +/// +/// Iterates through section IDs looking for any in the US range (7–23). +/// For the first match, decodes the section and extracts `sale_opt_out`. +/// +/// Returns `Some(true)` if the user opted out of sale, `Some(false)` if they +/// did not, or `None` if no US section is present. +fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { + use iab_gpp::sections::us_common::OptOut; + use iab_gpp::sections::Section; + + let us_section_id = parsed + .section_ids() + .find(|id| US_SECTION_ID_RANGE.contains(&(**id as u16)))?; + + match parsed.decode_section(*us_section_id) { + Ok(section) => { + let sale_opt_out = match §ion { + Section::UsNat(s) => match &s.core { + iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, + iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out, + }, + Section::UsCa(s) => &s.core.sale_opt_out, + Section::UsVa(s) => &s.core.sale_opt_out, + Section::UsCo(s) => &s.core.sale_opt_out, + Section::UsUt(s) => &s.core.sale_opt_out, + Section::UsCt(s) => &s.core.sale_opt_out, + Section::UsFl(s) => &s.core.sale_opt_out, + Section::UsMt(s) => &s.core.sale_opt_out, + Section::UsOr(s) => &s.core.sale_opt_out, + Section::UsTx(s) => &s.core.sale_opt_out, + Section::UsDe(s) => &s.core.sale_opt_out, + Section::UsIa(s) => &s.core.sale_opt_out, + Section::UsNe(s) => &s.core.sale_opt_out, + Section::UsNh(s) => &s.core.sale_opt_out, + Section::UsNj(s) => &s.core.sale_opt_out, + Section::UsTn(s) => &s.core.sale_opt_out, + Section::UsMn(s) => &s.core.sale_opt_out, + // Non-US sections — should not reach here given the ID filter. + _ => return None, + }; + Some(*sale_opt_out == OptOut::OptedOut) + } + Err(e) => { + log::warn!("Failed to decode US GPP section {us_section_id}: {e}"); + None + } + } +} +``` + +- [ ] **Step 4: Wire it into `decode_gpp_string`** + +In the same file, replace the placeholder in `decode_gpp_string`: + +```rust + let us_sale_opt_out = decode_us_sale_opt_out(&parsed); + + Ok(GppConsent { + version: 1, + section_ids, + eu_tcf, + us_sale_opt_out, + }) +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test --workspace -p trusted-server-core -- consent::gpp::tests` +Expected: all GPP tests pass, including the two new ones. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/consent/gpp.rs +git commit -m "Decode US sale opt-out from GPP sections" +``` + +--- + +## Task 3: Add GPP US branch to `allows_ec_creation()` + +**Files:** +- Modify: `crates/trusted-server-core/src/consent/mod.rs` + +- [ ] **Step 1: Write failing tests** + +Add to the `#[cfg(test)] mod tests` block in `crates/trusted-server-core/src/consent/mod.rs`: + +```rust + #[test] + fn ec_allowed_us_state_gpp_no_sale_opt_out() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "US state + GPP US sale_opt_out=false should allow EC" + ); + } + + #[test] + fn ec_blocked_us_state_gpp_sale_opted_out() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + !allows_ec_creation(&ctx), + "US state + GPP US sale_opt_out=true should block EC" + ); + } + + #[test] + fn ec_blocked_us_state_gpc_overrides_gpp_us() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpc: true, + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + ..ConsentContext::default() + }; + assert!( + !allows_ec_creation(&ctx), + "GPC should block EC even when GPP US says no opt-out" + ); + } + + #[test] + fn ec_us_state_tcf_takes_priority_over_gpp_us() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + tcf: Some(make_tcf_with_storage(true)), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "TCF consent should take priority over GPP US opt-out" + ); + } + + #[test] + fn ec_us_state_gpp_us_takes_priority_over_us_privacy() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "GPP US should take priority over us_privacy opt-out" + ); + } + + #[test] + fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: None, + us_sale_opt_out: None, + }), + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::No, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "GPP without US section should fall through to us_privacy" + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --workspace -p trusted-server-core -- consent::tests::ec_allowed_us_state_gpp` +Expected: FAIL — the GPP US branch doesn't exist yet, so `ec_allowed_us_state_gpp_no_sale_opt_out` fails (falls through to fail-closed). + +- [ ] **Step 3: Add the GPP US branch to `allows_ec_creation()`** + +In `crates/trusted-server-core/src/consent/mod.rs`, update `allows_ec_creation()`. The `UsState` arm currently reads: + +```rust + jurisdiction::Jurisdiction::UsState(_) => { + if ctx.gpc { + return false; + } + if let Some(tcf) = effective_tcf(ctx) { + return tcf.has_storage_consent(); + } + if let Some(usp) = &ctx.us_privacy { + return usp.opt_out_sale != PrivacyFlag::Yes; + } + false + } +``` + +Insert the GPP US check between TCF and us_privacy: + +```rust + jurisdiction::Jurisdiction::UsState(_) => { + if ctx.gpc { + return false; + } + if let Some(tcf) = effective_tcf(ctx) { + return tcf.has_storage_consent(); + } + // Check GPP US section for sale opt-out. + if let Some(gpp) = &ctx.gpp { + if let Some(opted_out) = gpp.us_sale_opt_out { + return !opted_out; + } + } + if let Some(usp) = &ctx.us_privacy { + return usp.opt_out_sale != PrivacyFlag::Yes; + } + false + } +``` + +- [ ] **Step 4: Run all tests** + +Run: `cargo test --workspace` +Expected: all tests pass, including the six new EC gating tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/consent/mod.rs +git commit -m "Recognize GPP US sale opt-out in EC consent gating" +``` + +--- + +## Task 4: Create Sourcepoint JS integration + +**Files:** +- Create: `crates/js/lib/src/integrations/sourcepoint/index.ts` + +- [ ] **Step 1: Write the test file first** + +Create `crates/js/lib/test/integrations/sourcepoint/index.test.ts`: + +```typescript +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; + +describe('integrations/sourcepoint', () => { + beforeEach(() => { + // Clear cookies and localStorage before each test. + document.cookie.split(';').forEach((c) => { + const name = c.split('=')[0].trim(); + if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + }); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('mirrors __gpp and __gpp_sid from _sp_user_consent_* localStorage', () => { + const payload = { + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [7], + }, + }; + localStorage.setItem('_sp_user_consent_36026', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(document.cookie).toContain('__gpp=DBABLA~BVQqAAAAAgA.QA'); + expect(document.cookie).toContain('__gpp_sid=7'); + }); + + it('handles multiple applicable sections', () => { + const payload = { + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [7, 8], + }, + }; + localStorage.setItem('_sp_user_consent_99999', JSON.stringify(payload)); + + mirrorSourcepointConsent(); + + expect(document.cookie).toContain('__gpp_sid=7,8'); + }); + + it('returns false when no _sp_user_consent_* key exists', () => { + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + expect(document.cookie).not.toContain('__gpp_sid='); + }); + + it('returns false for malformed JSON in localStorage', () => { + localStorage.setItem('_sp_user_consent_12345', 'not-json!!!'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('returns false when gppData is missing from payload', () => { + localStorage.setItem('_sp_user_consent_12345', JSON.stringify({ otherField: true })); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('returns false when gppString is empty', () => { + const payload = { + gppData: { + gppString: '', + applicableSections: [7], + }, + }; + localStorage.setItem('_sp_user_consent_12345', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd crates/js/lib && npx vitest run test/integrations/sourcepoint/index.test.ts` +Expected: FAIL — module `../../../src/integrations/sourcepoint` does not exist. + +- [ ] **Step 3: Implement the integration** + +Create `crates/js/lib/src/integrations/sourcepoint/index.ts`: + +```typescript +import { log } from '../../core/log'; + +const SP_CONSENT_PREFIX = '_sp_user_consent_'; + +interface SourcepointGppData { + gppString: string; + applicableSections: number[]; +} + +interface SourcepointConsentPayload { + gppData?: SourcepointGppData; +} + +function findSourcepointConsent(): SourcepointConsentPayload | null { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; + + const raw = localStorage.getItem(key); + if (!raw) continue; + + try { + return JSON.parse(raw) as SourcepointConsentPayload; + } catch { + log.debug('sourcepoint: failed to parse localStorage value', { key }); + return null; + } + } + return null; +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax`; +} + +/// Reads Sourcepoint consent from localStorage and mirrors it into +/// `__gpp` and `__gpp_sid` cookies for Trusted Server to read. +/// +/// Returns `true` if cookies were written, `false` otherwise. +export function mirrorSourcepointConsent(): boolean { + if (typeof localStorage === 'undefined' || typeof document === 'undefined') { + return false; + } + + const payload = findSourcepointConsent(); + if (!payload?.gppData) { + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + + const { gppString, applicableSections } = payload.gppData; + if (!gppString) { + log.debug('sourcepoint: gppString is empty'); + return false; + } + + writeCookie('__gpp', gppString); + + if (Array.isArray(applicableSections) && applicableSections.length > 0) { + writeCookie('__gpp_sid', applicableSections.join(',')); + } + + log.info('sourcepoint: mirrored GPP consent to cookies', { + gppLength: gppString.length, + sections: applicableSections, + }); + + return true; +} + +if (typeof window !== 'undefined') { + mirrorSourcepointConsent(); +} + +export default mirrorSourcepointConsent; +``` + +- [ ] **Step 4: Run tests** + +Run: `cd crates/js/lib && npx vitest run test/integrations/sourcepoint/index.test.ts` +Expected: all 6 tests pass. + +- [ ] **Step 5: Run the full JS test suite** + +Run: `cd crates/js/lib && npx vitest run` +Expected: all tests pass (existing + new). + +- [ ] **Step 6: Format** + +Run: `cd crates/js/lib && npm run format` +Expected: no formatting issues. + +- [ ] **Step 7: Commit** + +```bash +git add crates/js/lib/src/integrations/sourcepoint/index.ts \ + crates/js/lib/test/integrations/sourcepoint/index.test.ts +git commit -m "Add Sourcepoint JS integration for GPP consent cookie mirroring" +``` + +--- + +## Task 5: Final verification + +**Files:** None (verification only) + +- [ ] **Step 1: Build the JS bundles** + +Run: `cd crates/js/lib && node build-all.mjs` +Expected: builds successfully, `dist/tsjs-sourcepoint.js` appears in the output. + +- [ ] **Step 2: Full Rust build** + +Run: `cargo build --workspace` +Expected: compiles with no errors. + +- [ ] **Step 3: Full Rust test suite** + +Run: `cargo test --workspace` +Expected: all tests pass. + +- [ ] **Step 4: Clippy** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: no warnings. + +- [ ] **Step 5: Rust format check** + +Run: `cargo fmt --all -- --check` +Expected: no formatting issues. + +- [ ] **Step 6: Full JS test suite** + +Run: `cd crates/js/lib && npx vitest run` +Expected: all tests pass. + +- [ ] **Step 7: JS format check** + +Run: `cd crates/js/lib && npm run format` +Expected: no formatting issues. diff --git a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md new file mode 100644 index 00000000..725857db --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md @@ -0,0 +1,171 @@ +# Sourcepoint GPP Consent for Edge Cookie Generation + +**Issue:** #640 +**Date:** 2026-04-15 +**Status:** Approved + +## Problem + +Edge Cookie (EC) generation fails for sites using Sourcepoint when consent is +stored only in `localStorage` and not surfaced via the standard cookies Trusted +Server reads. Sourcepoint stores US consent under `_sp_user_consent_*` keys in +`localStorage`, including a full GPP string and applicable section IDs. + +Today, Trusted Server only reads consent from `euconsent-v2`, `__gpp`, +`__gpp_sid`, `us_privacy` cookies and the `Sec-GPC` header. Even if `__gpp` / +`__gpp_sid` were present, the server only decodes the EU TCF v2 section from +GPP — it does not use GPP US sections as a consent signal for EC gating. + +This creates two gaps: + +1. **Transport gap:** The server cannot read browser `localStorage`, so no + consent reaches the backend unless client code mirrors it into cookies. +2. **Semantics gap:** Even with `__gpp` / `__gpp_sid` cookies present, current + US-state EC gating does not recognize GPP US sections as valid consent. + +## Approach + +Thin GPP pass-through: mirror Sourcepoint localStorage consent into standard +cookies on the client, and extend server-side EC gating to recognize GPP US +`sale_opt_out` as a consent signal. No compatibility bridge (`us_privacy` +derivation) — both client and server changes ship together. + +## Design + +### 1. Client-side: Sourcepoint JS integration + +New JS-only integration at `crates/js/lib/src/integrations/sourcepoint/index.ts`. +No Rust-side `IntegrationRegistration` (same pattern as `creative`). + +**On page load:** + +1. Scan `localStorage` keys matching `_sp_user_consent_*`. +2. Take the first valid match, parse the JSON value. +3. Extract `gppData.gppString` and `gppData.applicableSections` from the payload. +4. Write first-party cookies: + - `__gpp=` (path `/`, `SameSite=Lax`) + - `__gpp_sid=` (path `/`, `SameSite=Lax`) + - `_ts_gpp_src=sp` marker (path `/`, `SameSite=Lax`) +5. Log what was written for debugging. + +Cookies are session-scoped (no `max-age` / `expires`) since the source of truth +stays in `localStorage` and we re-mirror on each page load. The marker cookie +tracks Trusted Server's Sourcepoint-owned writes so the integration only clears +`__gpp` / `__gpp_sid` values that it previously mirrored; this avoids clobbering +cookies written by other CMPs. This design assumes a single active Sourcepoint +property per page; if multiple `_sp_user_consent_*` entries coexist, the first +valid one wins. The integration runs immediately, performs bounded first-load +retries, and re-mirrors on page focus/visibility refresh so session cookies do +not remain stale after mid-session consent updates. + +### 2. Server-side: GPP US section decoding + +**`crates/trusted-server-core/src/consent/types.rs`** — extend `GppConsent`: + +```rust +pub struct GppConsent { + pub version: u8, + pub section_ids: Vec, + pub eu_tcf: Option, + pub us_sale_opt_out: Option, // new +} +``` + +- `Some(true)` — a US section is present and `sale_opt_out == OptedOut` +- `Some(false)` — a US section is present and `sale_opt_out != OptedOut` +- `None` — no US section exists in the GPP string + +**`crates/trusted-server-core/src/consent/gpp.rs`** — add `decode_us_sale_opt_out`: + +Checks for any US section ID (7–23) in the parsed `GPPString`. For the first +match, decodes the section via `iab_gpp` and extracts `sale_opt_out`. Maps +`OptOut::OptedOut` to `true`, everything else to `false`. + +The `iab_gpp` crate uses different structs per state (`UsNat`, `UsCa`, `UsTn`, +etc.) but they all have `sale_opt_out: OptOut` via `us_common`. We match on the +decoded `Section` enum to extract it. + +### 3. Server-side: EC gating update + +**`crates/trusted-server-core/src/consent/mod.rs`** — update `allows_ec_creation()` +for `Jurisdiction::UsState(_)`. + +New precedence chain: + +``` +GPC → TCF → GPP US sale_opt_out → us_privacy → fail-closed +``` + +Insert between the existing TCF and `us_privacy` branches: + +```rust +// Check GPP US section for sale opt-out. +if let Some(gpp) = &ctx.gpp { + if let Some(opted_out) = gpp.us_sale_opt_out { + return !opted_out; + } +} +``` + +Semantics: + +- GPC still short-circuits at the top and blocks EC creation. +- TCF still takes priority for CMPs like Didomi. In US-state jurisdictions, an + effective TCF Purpose 1 signal is treated as the authoritative EC storage + consent decision and is evaluated before GPP US sale opt-out. +- GPP US `sale_opt_out != OptedOut` → EC allowed when no effective TCF signal is + present. +- GPP US `sale_opt_out == OptedOut` → EC blocked when no effective TCF signal is + present. +- No GPP US section → falls through to `us_privacy`. + +The TCF-before-GPP precedence is intentional rather than accidental: it preserves +existing CMP behavior where TCF Purpose 1 is the explicit storage/access signal +for the EC cookie itself. Publishers that need US-section-wins behavior should +raise that as a separate consent-policy configuration change. + +### 4. Files touched + +| File | Change | +|---|---| +| `crates/js/lib/src/integrations/sourcepoint/index.ts` | New — localStorage auto-discovery, cookie mirroring | +| `crates/js/lib/test/integrations/sourcepoint/index.test.ts` | New — Vitest tests | +| `crates/trusted-server-core/src/consent/types.rs` | Add `us_sale_opt_out: Option` to `GppConsent` | +| `crates/trusted-server-core/src/consent/gpp.rs` | Add US section decoding, extract `sale_opt_out` | +| `crates/trusted-server-core/src/consent/mod.rs` | Add GPP US branch in `allows_ec_creation()`, tests | + +No config changes and no new crate dependencies. `IntegrationRegistry` includes +`sourcepoint` in the JS-only always-shipped module list; the client-side marker +cookie prevents the always-shipped module from clearing or overwriting other +CMPs' GPP cookies. + +### 5. Testing + +**JS (Vitest):** + +- Mirrors `__gpp` and `__gpp_sid` from `_sp_user_consent_*` localStorage +- No cookies written when no `_sp_user_consent_*` key exists +- Graceful handling of malformed JSON in localStorage + +**Rust — EC gating (`consent/mod.rs`):** + +- EC allowed: US state + GPP `us_sale_opt_out = Some(false)` +- EC blocked: US state + GPP `us_sale_opt_out = Some(true)` +- EC blocked: GPC overrides permissive GPP US +- TCF takes priority over GPP US when both present +- GPP US takes priority over `us_privacy` when both present +- No GPP US section falls through to `us_privacy` +- No signals → fail-closed + +**Rust — GPP decoding (`consent/gpp.rs`):** + +- Extracts `us_sale_opt_out` from GPP string with UsNat section (ID 7) +- `us_sale_opt_out` is `None` when GPP has no US sections + +### 6. Non-goals + +- No `us_privacy` compatibility bridge (skipped per decision) +- No richer US GPP field extraction (sharing, targeted advertising opt-outs) +- No publisher configuration for Sourcepoint property ID (auto-discovery) +- No Sourcepoint CMP API integration (localStorage-only approach) +- No consent-policy knob for making GPP US sale opt-out override TCF Purpose 1 diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index fb1289d3..46555fcf 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -32,7 +32,7 @@ echo "==> Validating shared integration-test dependency versions..." echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ -TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret" \ +TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 318b9323..6f40f62b 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -53,7 +53,7 @@ fi echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ -TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret" \ +TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1