diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b30b94..5bc49c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries. +- **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading. ### Security @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/crates/js/lib/src/integrations/osano/index.ts b/crates/js/lib/src/integrations/osano/index.ts new file mode 100644 index 00000000..8bd3daae --- /dev/null +++ b/crates/js/lib/src/integrations/osano/index.ts @@ -0,0 +1,514 @@ +import { log } from '../../core/log'; + +const MARKER_COOKIE_NAME = '_ts_consent_src'; +const MARKER_COOKIE_VALUE = 'osano'; +const US_PRIVACY_COOKIE_NAME = 'us_privacy'; +const GPP_COOKIE_NAME = '__gpp'; +const GPP_SID_COOKIE_NAME = '__gpp_sid'; +const TCF_COOKIE_NAME = 'euconsent-v2'; +const TARGET_COOKIE_NAMES = [ + US_PRIVACY_COOKIE_NAME, + GPP_COOKIE_NAME, + GPP_SID_COOKIE_NAME, + TCF_COOKIE_NAME, +]; +const API_TIMEOUT_MS = 500; +const OSANO_RETRY_DELAY_MS = 250; +const OSANO_MAX_RETRIES = 20; +const MIRROR_DEBOUNCE_MS = 0; + +const OSANO_EVENTS = [ + 'osano-cm-initialized', + 'osano-cm-consent-saved', + 'osano-cm-consent-new', + 'osano-cm-consent-changed', + 'osano-cm-opt-out', + 'osano-cm-storage', +] as const; +const OSANO_CLEAR_READY_EVENTS = new Set([ + 'osano-cm-initialized', + 'osano-cm-consent-saved', + 'osano-cm-consent-new', + 'osano-cm-consent-changed', + 'osano-cm-opt-out', +]); + +interface UspData { + uspString?: string; +} + +interface GppPingData { + signalStatus?: string; + gppString?: string; + applicableSections?: number[]; +} + +interface TcfData { + tcString?: string; + eventStatus?: string; +} + +interface OsanoCm { + addEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; + removeEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; +} + +type UspApi = ( + command: 'getUSPData', + version: 1, + callback: (data?: UspData, success?: boolean) => void +) => void; + +type GppApi = (command: 'ping', callback: (data?: GppPingData, success?: boolean) => void) => void; + +type TcfApi = ( + command: 'getTCData', + version: 2, + callback: (data?: TcfData, success?: boolean) => void +) => void; + +type OsanoWindow = Window & { + Osano?: { + cm?: OsanoCm; + }; + __uspapi?: UspApi; + __gpp?: GppApi; + __tcfapi?: TcfApi; +}; + +interface CookieWrite { + name: string; + value: string; +} + +interface SignalResult { + writes: CookieWrite[]; + clears: string[]; + pending: boolean; +} + +interface MirrorPlan { + writes: CookieWrite[]; + clears: string[]; + pending: boolean; +} + +let initialized = false; +let osanoListenersInstalled = false; +let osanoRetryCount = 0; +let osanoReadyForClears = false; +let osanoRetryTimer: number | undefined; +let mirrorTimer: number | undefined; +let mirrorGeneration = 0; +let osanoEventHandlers: Map void> | undefined; +let focusHandler: (() => void) | undefined; +let visibilityHandler: (() => void) | undefined; + +function getWindow(): OsanoWindow | undefined { + if (typeof window === 'undefined') return undefined; + return window as OsanoWindow; +} + +function readCookie(name: string): string | undefined { + if (typeof document === 'undefined') return undefined; + + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + return cookie?.slice(prefix.length); +} + +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 hasAnyTargetCookie(): boolean { + return TARGET_COOKIE_NAMES.some((name) => readCookie(name) !== undefined); +} + +function ownsConsentCookies(): boolean { + return readCookie(MARKER_COOKIE_NAME) === MARKER_COOKIE_VALUE; +} + +function canWriteConsentCookies(): boolean { + const marker = readCookie(MARKER_COOKIE_NAME); + if (marker === MARKER_COOKIE_VALUE) return true; + + if (marker !== undefined) { + log.debug('osano: preserving consent cookies owned by another mirror', { marker }); + return false; + } + + if (hasAnyTargetCookie()) { + log.debug('osano: preserving existing unmarked consent cookies'); + return false; + } + + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === 'number'); +} + +function shouldWriteGppSid( + applicableSections: number[] | undefined +): applicableSections is number[] { + return ( + Array.isArray(applicableSections) && + applicableSections.length > 0 && + !applicableSections.includes(-1) + ); +} + +function isTcfReady(eventStatus: unknown): boolean { + return eventStatus === 'tcloaded' || eventStatus === 'useractioncomplete'; +} + +function signalResult(writes: CookieWrite[] = [], clears: string[] = []): SignalResult { + return { writes, clears, pending: false }; +} + +function pendingResult(): SignalResult { + return { writes: [], clears: [], pending: true }; +} + +function unavailableResult(): SignalResult { + return { writes: [], clears: [], pending: false }; +} + +function emptyAfterOsanoReadyResult(cookieNames: string | string[]): SignalResult { + if (!osanoReadyForClears) { + return pendingResult(); + } + + return signalResult([], Array.isArray(cookieNames) ? cookieNames : [cookieNames]); +} + +function finishOnce(finish: (value: T) => void): (value: T) => void { + let settled = false; + return (value: T): void => { + if (settled) return; + settled = true; + finish(value); + }; +} + +function readUspSignal(win: OsanoWindow): Promise { + if (typeof win.__uspapi !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + const done = finishOnce((result: SignalResult) => { + window.clearTimeout(timer); + resolve(result); + }); + const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); + + try { + win.__uspapi?.('getUSPData', 1, (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if ('uspString' in data && typeof data.uspString !== 'string') { + done(pendingResult()); + return; + } + + if (typeof data.uspString === 'string' && data.uspString.length > 0) { + done(signalResult([{ name: US_PRIVACY_COOKIE_NAME, value: data.uspString }])); + return; + } + + done(emptyAfterOsanoReadyResult(US_PRIVACY_COOKIE_NAME)); + }); + } catch (error) { + log.debug('osano: __uspapi getUSPData failed', { error }); + done(pendingResult()); + } + }); +} + +function readGppSignal(win: OsanoWindow): Promise { + if (typeof win.__gpp !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + const done = finishOnce((result: SignalResult) => { + window.clearTimeout(timer); + resolve(result); + }); + const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); + + try { + win.__gpp?.('ping', (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if (data.signalStatus !== 'ready') { + done(pendingResult()); + return; + } + + if ('gppString' in data && typeof data.gppString !== 'string') { + done(pendingResult()); + return; + } + + if ( + 'applicableSections' in data && + data.applicableSections !== undefined && + !isNumberArray(data.applicableSections) + ) { + done(pendingResult()); + return; + } + + const applicableSections = data.applicableSections; + if (typeof data.gppString === 'string' && data.gppString.length > 0) { + const writes = [{ name: GPP_COOKIE_NAME, value: data.gppString }]; + const clears: string[] = []; + + if (shouldWriteGppSid(applicableSections)) { + writes.push({ name: GPP_SID_COOKIE_NAME, value: applicableSections.join(',') }); + } else { + clears.push(GPP_SID_COOKIE_NAME); + } + + done(signalResult(writes, clears)); + return; + } + + done(emptyAfterOsanoReadyResult([GPP_COOKIE_NAME, GPP_SID_COOKIE_NAME])); + }); + } catch (error) { + log.debug('osano: __gpp ping failed', { error }); + done(pendingResult()); + } + }); +} + +function readTcfSignal(win: OsanoWindow): Promise { + if (typeof win.__tcfapi !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + const done = finishOnce((result: SignalResult) => { + window.clearTimeout(timer); + resolve(result); + }); + const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); + + try { + win.__tcfapi?.('getTCData', 2, (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if (!isTcfReady(data.eventStatus)) { + done(pendingResult()); + return; + } + + if ('tcString' in data && typeof data.tcString !== 'string') { + done(pendingResult()); + return; + } + + if (typeof data.tcString === 'string' && data.tcString.length > 0) { + done(signalResult([{ name: TCF_COOKIE_NAME, value: data.tcString }])); + return; + } + + done(emptyAfterOsanoReadyResult(TCF_COOKIE_NAME)); + }); + } catch (error) { + log.debug('osano: __tcfapi getTCData failed', { error }); + done(pendingResult()); + } + }); +} + +async function buildMirrorPlan(win: OsanoWindow): Promise { + const results = await Promise.all([readUspSignal(win), readGppSignal(win), readTcfSignal(win)]); + + return { + writes: results.flatMap((result) => result.writes), + clears: results.flatMap((result) => result.clears), + pending: results.some((result) => result.pending), + }; +} + +function applyMirrorPlan(plan: MirrorPlan): boolean { + if (plan.writes.length === 0 && plan.clears.length === 0) { + return false; + } + + if (!canWriteConsentCookies()) { + return false; + } + + const writeNames = new Set(plan.writes.map((write) => write.name)); + for (const name of plan.clears) { + if (!writeNames.has(name)) clearCookie(name); + } + + for (const write of plan.writes) { + writeCookie(write.name, write.value); + } + + if (hasAnyTargetCookie()) { + writeCookie(MARKER_COOKIE_NAME, MARKER_COOKIE_VALUE); + } else if (ownsConsentCookies()) { + clearCookie(MARKER_COOKIE_NAME); + } + + log.info('osano: mirrored consent to standard cookies', { + writes: plan.writes.map((write) => write.name), + clears: plan.clears, + pending: plan.pending, + }); + + return true; +} + +/** + * Mirrors Osano's IAB API consent signals into standard first-party cookies. + * + * Returns `true` when any cookie was written or cleared, `false` otherwise. + */ +export async function mirrorOsanoConsent(): Promise { + if (typeof document === 'undefined') return false; + + const win = getWindow(); + if (!win) return false; + + const generation = (mirrorGeneration += 1); + const plan = await buildMirrorPlan(win); + + if (generation !== mirrorGeneration) { + return false; + } + + return applyMirrorPlan(plan); +} + +function scheduleMirror(): void { + if (mirrorTimer !== undefined || typeof window === 'undefined') return; + + mirrorTimer = window.setTimeout(() => { + mirrorTimer = undefined; + void mirrorOsanoConsent(); + }, MIRROR_DEBOUNCE_MS); +} + +function installOsanoListeners(): boolean { + const cm = getWindow()?.Osano?.cm; + if (!cm) return false; + + if (osanoListenersInstalled) { + scheduleMirror(); + return true; + } + + if (typeof cm.addEventListener !== 'function') { + return false; + } + + osanoEventHandlers = new Map(); + for (const eventName of OSANO_EVENTS) { + const handler = (): void => { + if (OSANO_CLEAR_READY_EVENTS.has(eventName)) { + osanoReadyForClears = true; + } + scheduleMirror(); + }; + osanoEventHandlers.set(eventName, handler); + cm.addEventListener(eventName, handler); + } + osanoListenersInstalled = true; + + scheduleMirror(); + return true; +} + +function scheduleOsanoRetry(): void { + if (osanoRetryTimer !== undefined || osanoRetryCount >= OSANO_MAX_RETRIES) return; + + osanoRetryCount += 1; + osanoRetryTimer = window.setTimeout(() => { + osanoRetryTimer = undefined; + if (!installOsanoListeners()) { + scheduleOsanoRetry(); + } + }, OSANO_RETRY_DELAY_MS); +} + +function mirrorOnVisible(): void { + if (document.visibilityState === 'visible') { + scheduleMirror(); + } +} + +/** + * Initializes the Osano consent mirror. + */ +export function initializeOsanoConsentMirror(): void { + if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + initialized = true; + focusHandler = () => scheduleMirror(); + visibilityHandler = () => mirrorOnVisible(); + window.addEventListener('focus', focusHandler); + document.addEventListener('visibilitychange', visibilityHandler); + + scheduleMirror(); + + if (!installOsanoListeners()) { + scheduleOsanoRetry(); + } +} + +/** Resets module state for unit tests. */ +export function resetOsanoConsentMirrorForTest(): void { + const cm = getWindow()?.Osano?.cm; + if ( + osanoListenersInstalled && + osanoEventHandlers && + cm && + typeof cm.removeEventListener === 'function' + ) { + for (const [eventName, handler] of osanoEventHandlers) { + cm.removeEventListener(eventName, handler); + } + } + + if (focusHandler) window.removeEventListener('focus', focusHandler); + if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); + if (osanoRetryTimer !== undefined) window.clearTimeout(osanoRetryTimer); + if (mirrorTimer !== undefined) window.clearTimeout(mirrorTimer); + + initialized = false; + osanoListenersInstalled = false; + osanoRetryCount = 0; + osanoReadyForClears = false; + mirrorGeneration = 0; + osanoRetryTimer = undefined; + mirrorTimer = undefined; + osanoEventHandlers = undefined; + focusHandler = undefined; + visibilityHandler = undefined; +} + +initializeOsanoConsentMirror(); diff --git a/crates/js/lib/test/integrations/osano/index.test.ts b/crates/js/lib/test/integrations/osano/index.test.ts new file mode 100644 index 00000000..932bda22 --- /dev/null +++ b/crates/js/lib/test/integrations/osano/index.test.ts @@ -0,0 +1,424 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + initializeOsanoConsentMirror, + mirrorOsanoConsent, + resetOsanoConsentMirrorForTest, +} from '../../../src/integrations/osano'; + +type TestWindow = Window & { + Osano?: { + cm?: { + addEventListener?: ReturnType; + removeEventListener?: ReturnType; + }; + }; + __uspapi?: ReturnType; + __gpp?: ReturnType; + __tcfapi?: ReturnType; +}; + +const MARKER_COOKIE = '_ts_consent_src'; + +type UspCallback = (data?: { uspString?: string }, success?: boolean) => void; + +function clearAllCookies(): void { + document.cookie.split(';').forEach((cookie) => { + const name = cookie.split('=')[0].trim(); + if (name) document.cookie = `${name}=; path=/; Max-Age=0`; + }); +} + +function getCookie(name: string): string | undefined { + const match = document.cookie.split('; ').find((cookie) => cookie.startsWith(`${name}=`)); + return match ? match.split('=').slice(1).join('=') : undefined; +} + +function setUspApi(uspString: string | undefined, success = true): void { + (window as TestWindow).__uspapi = vi.fn((_command, _version, callback) => { + callback(uspString === undefined ? {} : { uspString }, success); + }); +} + +function setControlledUspApi(): UspCallback[] { + const callbacks: UspCallback[] = []; + (window as TestWindow).__uspapi = vi.fn((_command, _version, callback: UspCallback) => { + callbacks.push(callback); + }); + return callbacks; +} + +function setGppApi( + gppString: string | undefined, + applicableSections: number[] | undefined, + signalStatus = 'ready', + success = true +): void { + (window as TestWindow).__gpp = vi.fn((_command, callback) => { + callback({ signalStatus, gppString, applicableSections }, success); + }); +} + +function setTcfApi(tcString: string | undefined, success = true, eventStatus = 'tcloaded'): void { + (window as TestWindow).__tcfapi = vi.fn((_command, _version, callback) => { + callback(tcString === undefined ? { eventStatus } : { tcString, eventStatus }, success); + }); +} + +function setOsanoStub(): Record void> { + const listeners: Record void> = {}; + (window as TestWindow).Osano = { + cm: { + addEventListener: vi.fn((eventName: string, callback: (payload?: unknown) => void) => { + listeners[eventName] = callback; + }), + removeEventListener: vi.fn(), + }, + }; + return listeners; +} + +describe('integrations/osano consent mirror', () => { + beforeEach(() => { + resetOsanoConsentMirrorForTest(); + clearAllCookies(); + delete (window as TestWindow).Osano; + delete (window as TestWindow).__uspapi; + delete (window as TestWindow).__gpp; + delete (window as TestWindow).__tcfapi; + }); + + afterEach(() => { + vi.useRealTimers(); + resetOsanoConsentMirrorForTest(); + clearAllCookies(); + delete (window as TestWindow).Osano; + delete (window as TestWindow).__uspapi; + delete (window as TestWindow).__gpp; + delete (window as TestWindow).__tcfapi; + }); + + it('does nothing when Osano IAB APIs are unavailable', async () => { + const result = await mirrorOsanoConsent(); + + expect(result).toBe(false); + expect(document.cookie).toBe(''); + }); + + it('mirrors US Privacy no-opt-out values from __uspapi', async () => { + setUspApi('1YN-'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(true); + expect(getCookie('us_privacy')).toBe('1YN-'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('mirrors US Privacy opt-out values from __uspapi', async () => { + setUspApi('1YY-'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(true); + expect(getCookie('us_privacy')).toBe('1YY-'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('mirrors GPP cookies only when GPP signal status is ready', async () => { + setGppApi('DBABLA~BVQqAAAAAgA.QA', [7, 8]); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); + expect(getCookie('__gpp_sid')).toBe('7,8'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('does not mirror or clear GPP cookies while GPP is not ready', async () => { + document.cookie = '__gpp=stale-gpp; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + setGppApi('updated-gpp', [7], 'not ready'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(false); + expect(getCookie('__gpp')).toBe('stale-gpp'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('omits __gpp_sid for empty or not-applicable section lists', async () => { + document.cookie = '__gpp_sid=stale; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + setGppApi('ready-gpp', [-1]); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('ready-gpp'); + expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('mirrors TCF consent strings from __tcfapi', async () => { + setTcfApi('CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(true); + expect(getCookie('euconsent-v2')).toBe('CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('preserves existing unmarked consent cookies', async () => { + document.cookie = 'us_privacy=external; path=/'; + setUspApi('1YN-'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(false); + expect(getCookie('us_privacy')).toBe('external'); + expect(getCookie(MARKER_COOKIE)).toBeUndefined(); + }); + + it('preserves consent cookies owned by another mirror', async () => { + document.cookie = `${MARKER_COOKIE}=sourcepoint; path=/`; + setUspApi('1YN-'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(false); + expect(getCookie('us_privacy')).toBeUndefined(); + expect(getCookie(MARKER_COOKIE)).toBe('sourcepoint'); + }); + + it('updates cookies when Osano owns the marker', async () => { + document.cookie = 'us_privacy=stale; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + setUspApi('1YN-'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(true); + expect(getCookie('us_privacy')).toBe('1YN-'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('does not let older overlapping mirror attempts overwrite newer consent', async () => { + const callbacks = setControlledUspApi(); + + const olderAttempt = mirrorOsanoConsent(); + const newerAttempt = mirrorOsanoConsent(); + + const olderCallback = callbacks[0]; + const newerCallback = callbacks[1]; + if (!olderCallback || !newerCallback) { + throw new Error('should capture both USP API callbacks'); + } + + newerCallback({ uspString: '1YY-' }, true); + + await expect(newerAttempt).resolves.toBe(true); + expect(getCookie('us_privacy')).toBe('1YY-'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + + olderCallback({ uspString: '1YN-' }, true); + + await expect(olderAttempt).resolves.toBe(false); + expect(getCookie('us_privacy')).toBe('1YY-'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('preserves stale Osano-owned cookies until Osano is ready for clearing', async () => { + vi.useFakeTimers(); + document.cookie = 'us_privacy=stale; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + setOsanoStub(); + setUspApi(undefined); + + initializeOsanoConsentMirror(); + await vi.runOnlyPendingTimersAsync(); + + expect(getCookie('us_privacy')).toBe('stale'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('clears stale Osano-owned cookies when a ready API definitively has no value', async () => { + vi.useFakeTimers(); + document.cookie = 'us_privacy=stale; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + const listeners = setOsanoStub(); + setUspApi(undefined); + + initializeOsanoConsentMirror(); + await vi.runOnlyPendingTimersAsync(); + listeners['osano-cm-initialized']?.(); + await vi.runOnlyPendingTimersAsync(); + + expect(getCookie('us_privacy')).toBeUndefined(); + expect(getCookie(MARKER_COOKIE)).toBeUndefined(); + }); + + it('preserves stale Osano-owned GPP cookies until Osano is ready for clearing', async () => { + vi.useFakeTimers(); + document.cookie = '__gpp=stale-gpp; path=/'; + document.cookie = '__gpp_sid=7,8; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + setOsanoStub(); + setGppApi('', undefined); + + initializeOsanoConsentMirror(); + await vi.runOnlyPendingTimersAsync(); + + expect(getCookie('__gpp')).toBe('stale-gpp'); + expect(getCookie('__gpp_sid')).toBe('7,8'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('clears stale Osano-owned GPP cookies when a ready API definitively has no value', async () => { + vi.useFakeTimers(); + document.cookie = '__gpp=stale-gpp; path=/'; + document.cookie = '__gpp_sid=7,8; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + const listeners = setOsanoStub(); + setGppApi('', undefined); + + initializeOsanoConsentMirror(); + await vi.runOnlyPendingTimersAsync(); + listeners['osano-cm-initialized']?.(); + await vi.runOnlyPendingTimersAsync(); + + expect(getCookie('__gpp')).toBeUndefined(); + expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(MARKER_COOKIE)).toBeUndefined(); + }); + + it('does not clear cookies when an API callback returns malformed data', async () => { + vi.useFakeTimers(); + document.cookie = 'us_privacy=stale; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + const listeners = setOsanoStub(); + (window as TestWindow).__uspapi = vi.fn((_command, _version, callback) => { + callback({ uspString: 123 }, true); + }); + + initializeOsanoConsentMirror(); + listeners['osano-cm-initialized']?.(); + await vi.runOnlyPendingTimersAsync(); + + expect(getCookie('us_privacy')).toBe('stale'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('does not mirror or clear TCF cookies before TCF is ready', async () => { + document.cookie = 'euconsent-v2=stale-tcf; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + setTcfApi('CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', true, 'cmpuishown'); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(false); + expect(getCookie('euconsent-v2')).toBe('stale-tcf'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('does not clear cookies when an API callback reports failure', async () => { + document.cookie = 'us_privacy=stale; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + setUspApi('1YN-', false); + + const result = await mirrorOsanoConsent(); + + expect(result).toBe(false); + expect(getCookie('us_privacy')).toBe('stale'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('does not clear cookies when an API times out', async () => { + vi.useFakeTimers(); + document.cookie = 'us_privacy=stale; path=/'; + document.cookie = `${MARKER_COOKIE}=osano; path=/`; + (window as TestWindow).__uspapi = vi.fn(); + + const pending = mirrorOsanoConsent(); + await vi.advanceTimersByTimeAsync(500); + const result = await pending; + + expect(result).toBe(false); + expect(getCookie('us_privacy')).toBe('stale'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('mirrors available IAB APIs on initialization before Osano listeners exist', async () => { + vi.useFakeTimers(); + setUspApi('1YN-'); + + initializeOsanoConsentMirror(); + await vi.runOnlyPendingTimersAsync(); + + expect(getCookie('us_privacy')).toBe('1YN-'); + expect(getCookie(MARKER_COOKIE)).toBe('osano'); + }); + + it('registers Osano listeners and mirrors returning consent on initialization', async () => { + vi.useFakeTimers(); + const listeners = setOsanoStub(); + setUspApi('1YN-'); + + initializeOsanoConsentMirror(); + await vi.runOnlyPendingTimersAsync(); + + expect((window as TestWindow).Osano?.cm?.addEventListener).toHaveBeenCalledWith( + 'osano-cm-consent-saved', + expect.any(Function) + ); + expect(getCookie('us_privacy')).toBe('1YN-'); + + setUspApi('1YY-'); + listeners['osano-cm-consent-saved']?.({ OPT_OUT: 'ACCEPT' }); + await vi.runOnlyPendingTimersAsync(); + + expect(getCookie('us_privacy')).toBe('1YY-'); + }); + + it('retries boundedly when Osano appears after initialization', async () => { + vi.useFakeTimers(); + setUspApi('1YN-'); + + initializeOsanoConsentMirror(); + const listeners = setOsanoStub(); + await vi.advanceTimersByTimeAsync(250); + await vi.runOnlyPendingTimersAsync(); + + expect((window as TestWindow).Osano?.cm?.addEventListener).toHaveBeenCalled(); + expect(listeners['osano-cm-consent-saved']).toEqual(expect.any(Function)); + expect(getCookie('us_privacy')).toBe('1YN-'); + }); + + it('keeps retrying when Osano cm appears before listener methods', async () => { + vi.useFakeTimers(); + setUspApi('1YN-'); + const listeners: Record void> = {}; + const cm: NonNullable['cm']> = {}; + (window as TestWindow).Osano = { cm }; + + initializeOsanoConsentMirror(); + await vi.advanceTimersByTimeAsync(250); + + cm.addEventListener = vi.fn((eventName: string, callback: (payload?: unknown) => void) => { + listeners[eventName] = callback; + }); + cm.removeEventListener = vi.fn(); + + await vi.advanceTimersByTimeAsync(250); + await vi.runOnlyPendingTimersAsync(); + + expect(cm.addEventListener).toHaveBeenCalledWith( + 'osano-cm-consent-saved', + expect.any(Function) + ); + expect(listeners['osano-cm-consent-saved']).toEqual(expect.any(Function)); + expect(getCookie('us_privacy')).toBe('1YN-'); + }); +}); diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 297cf9d0..f3e4f099 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -19,6 +19,7 @@ pub mod google_tag_manager; pub mod gpt; pub mod lockr; pub mod nextjs; +pub mod osano; pub mod permutive; pub mod prebid; mod registry; @@ -210,6 +211,7 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { lockr::register, didomi::register, sourcepoint::register, + osano::register, google_tag_manager::register, datadome::register, gpt::register, diff --git a/crates/trusted-server-core/src/integrations/osano.rs b/crates/trusted-server-core/src/integrations/osano.rs new file mode 100644 index 00000000..f2e006c7 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/osano.rs @@ -0,0 +1,96 @@ +//! Osano integration for client-side consent mirroring. +//! +//! The Rust side of this integration intentionally only provides explicit +//! enablement for the `tsjs-osano` browser module. Osano consent extraction runs +//! in JavaScript because the relevant CMP APIs (`__uspapi`, `__gpp`, and +//! `__tcfapi`) are browser-only. + +use error_stack::Report; +use serde::Deserialize; +use validator::Validate; + +use crate::error::TrustedServerError; +use crate::settings::{IntegrationConfig, Settings}; + +use super::IntegrationRegistration; + +const OSANO_INTEGRATION_ID: &str = "osano"; + +/// Configuration for the Osano consent mirror integration. +#[derive(Debug, Clone, Deserialize, Validate)] +pub struct OsanoConfig { + /// Whether the Osano browser consent mirror is enabled. + #[serde(default)] + pub enabled: bool, +} + +impl IntegrationConfig for OsanoConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// Register the Osano JS integration when enabled. +/// +/// # Errors +/// +/// Returns an error when the Osano integration configuration cannot be parsed or +/// fails validation. +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(_config) = settings.integration_config::(OSANO_INTEGRATION_ID)? else { + return Ok(None); + }; + + Ok(Some( + IntegrationRegistration::builder(OSANO_INTEGRATION_ID).build(), + )) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::register; + use crate::test_support::tests::create_test_settings; + + #[test] + fn register_returns_none_when_disabled() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("osano", &json!({ "enabled": false })) + .expect("should insert osano config"); + + let registration = register(&settings).expect("should parse disabled osano config"); + + assert!( + registration.is_none(), + "disabled Osano integration should not register" + ); + } + + #[test] + fn register_returns_js_module_registration_when_enabled() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("osano", &json!({ "enabled": true })) + .expect("should insert osano config"); + + let registration = register(&settings) + .expect("should parse enabled osano config") + .expect("enabled Osano integration should register"); + + assert_eq!(registration.integration_id, "osano"); + assert!( + registration.proxies.is_empty(), + "Osano v1 should not register Rust proxy routes" + ); + assert!( + registration.head_injectors.is_empty(), + "Osano v1 should not inject HTML from Rust" + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 99df1c73..3df172e2 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -727,6 +727,7 @@ struct IntegrationRegistryInner { // Metadata for introspection routes: Vec<(IntegrationEndpoint, &'static str)>, + enabled_integration_ids: Vec<&'static str>, deferred_js_ids: Vec<&'static str>, html_rewriters: Vec>, script_rewriters: Vec>, @@ -746,6 +747,7 @@ impl Default for IntegrationRegistryInner { head_router: Router::new(), options_router: Router::new(), routes: Vec::new(), + enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), @@ -816,6 +818,10 @@ impl IntegrationRegistry { for builder in crate::integrations::builders() { if let Some(registration) = builder(settings)? { + inner + .enabled_integration_ids + .push(registration.integration_id); + for proxy in registration.proxies { for route in proxy.routes() { let value = (proxy.clone(), registration.integration_id); @@ -1082,6 +1088,11 @@ impl IntegrationRegistry { pub fn registered_integrations(&self) -> Vec { let mut map: BTreeMap<&'static str, IntegrationMetadata> = BTreeMap::new(); + for integration_id in &self.inner.enabled_integration_ids { + map.entry(*integration_id) + .or_insert_with(|| IntegrationMetadata::new(integration_id)); + } + for (route, integration_id) in &self.inner.routes { let entry = map .entry(*integration_id) @@ -1126,20 +1137,18 @@ impl IntegrationRegistry { /// Return JS module IDs that should be included in the tsjs bundle. /// /// Always includes JS-only modules with no Rust-side registration. - /// Excludes integrations that have no JS module (e.g., "nextjs"). + /// Includes enabled integrations only when the generated TSJS registry has a + /// corresponding browser module. #[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). - // Sourcepoint's JS guards cookie clearing with a Sourcepoint-owned marker. - const JS_ALWAYS: &[&str] = &["creative", "sourcepoint"]; + // Core JS-only modules that do not have a Rust-side registration. + const JS_ALWAYS: &[&str] = &["creative"]; let mut ids: Vec<&'static str> = JS_ALWAYS.to_vec(); - for meta in self.registered_integrations() { - if !JS_EXCLUDED.contains(&meta.id) && !ids.contains(&meta.id) { - ids.push(meta.id); + for id in &self.inner.enabled_integration_ids { + if trusted_server_js::module_bundle(id).is_some() && !ids.contains(id) { + ids.push(*id); } } @@ -1186,6 +1195,7 @@ impl IntegrationRegistry { head_router: Router::new(), options_router: Router::new(), routes: Vec::new(), + enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, script_rewriters, html_post_processors: Vec::new(), @@ -1213,6 +1223,7 @@ impl IntegrationRegistry { head_router: Router::new(), options_router: Router::new(), routes: Vec::new(), + enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, script_rewriters, html_post_processors: Vec::new(), @@ -1236,6 +1247,7 @@ impl IntegrationRegistry { head_router: Router::new(), options_router: Router::new(), routes: Vec::new(), + enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), @@ -1299,6 +1311,7 @@ impl IntegrationRegistry { head_router, options_router, routes: Vec::new(), + enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), @@ -1951,7 +1964,7 @@ mod tests { } #[test] - fn js_module_ids_immediate_excludes_prebid_and_includes_js_only_modules() { + fn js_module_ids_immediate_excludes_prebid_and_includes_core_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1984,8 +1997,8 @@ mod tests { "should include creative in immediate IDs" ); assert!( - immediate.contains(&"sourcepoint"), - "should include sourcepoint in immediate IDs" + !immediate.contains(&"sourcepoint"), + "should not include Sourcepoint unless explicitly enabled" ); assert!( !immediate.contains(&"prebid"), @@ -1997,6 +2010,62 @@ mod tests { ); } + #[test] + fn js_module_ids_skip_enabled_integrations_without_generated_js_module() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config("nextjs", &serde_json::json!({ "enabled": true })) + .expect("should insert nextjs config"); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let all = registry.js_module_ids(); + + assert!( + !all.contains(&"nextjs"), + "should not include enabled integrations without generated JS modules" + ); + + let metadata = registry.registered_integrations(); + assert!( + metadata + .iter() + .any(|integration| integration.id == "nextjs"), + "should still register enabled Rust-only integrations" + ); + } + + #[test] + fn js_module_ids_include_explicitly_enabled_cmp_mirrors() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config("sourcepoint", &serde_json::json!({ "enabled": true })) + .expect("should insert sourcepoint config"); + settings + .integrations + .insert_config("osano", &serde_json::json!({ "enabled": true })) + .expect("should insert osano config"); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let immediate = registry.js_module_ids_immediate(); + + assert!( + immediate.contains(&"sourcepoint"), + "should include Sourcepoint when explicitly enabled" + ); + assert!( + immediate.contains(&"osano"), + "should include Osano when explicitly enabled" + ); + + let metadata = registry.registered_integrations(); + assert!( + metadata.iter().any(|integration| integration.id == "osano"), + "should include JS-only Osano registration in metadata" + ); + } + #[test] fn js_module_ids_deferred_empty_when_prebid_disabled() { let mut settings = crate::test_support::tests::create_test_settings(); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ec0830ab..1929c5ae 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -137,7 +137,10 @@ export default withMermaid( }, { text: 'CMP', - items: [{ text: 'Didomi', link: '/guide/integrations/didomi' }], + items: [ + { text: 'Didomi', link: '/guide/integrations/didomi' }, + { text: 'Osano', link: '/guide/integrations/osano' }, + ], }, { text: 'Data', diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 41b996fa..05330f76 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -899,7 +899,7 @@ See [Asset Routes](/guide/asset-routes) for request flow, S3 auth details, and I ## Integration Configurations -Settings for built-in integrations (Prebid, Next.js, Permutive, Testlight). For other +Settings for built-in integrations (Prebid, Next.js, Osano, Permutive, Testlight). For other integrations (APS, Didomi, Lockr, GAM, etc.), see the relevant integration guides. ### Common Fields @@ -1022,6 +1022,29 @@ TRUSTED_SERVER__INTEGRATIONS__NEXTJS__REWRITE_ATTRIBUTES=href,link,url,src TRUSTED_SERVER__INTEGRATIONS__NEXTJS__MAX_COMBINED_PAYLOAD_BYTES=10485760 ``` +### Osano Integration + +**Section**: `[integrations.osano]` + +| Field | Type | Default | Description | +| --------- | ------- | ------- | --------------------------------------- | +| `enabled` | Boolean | `false` | Enable the Osano browser consent mirror | + +**Example**: + +```toml +[integrations.osano] +enabled = true +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__INTEGRATIONS__OSANO__ENABLED=true +``` + +The Osano mirror runs in the browser, so consent cookies it writes are available to Trusted Server on requests after the page where Osano consent APIs become ready. See [Osano Integration](/guide/integrations/osano) for details. + ### Permutive Integration **Section**: `[integrations.permutive]` diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md index af6e0894..a0cc3cda 100644 --- a/docs/guide/integrations-overview.md +++ b/docs/guide/integrations-overview.md @@ -10,6 +10,7 @@ Trusted Server provides built-in integrations with popular third-party services, | **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 | +| **Osano** | Browser Mirror | None | Consent cookie mirroring | First-party consent signals | Development | | **Testlight** | Proxy + Rewriter | 1 route | Rewrites integration scripts | Testing/development | Development | ## Integration Details @@ -153,6 +154,35 @@ cache_ttl_seconds = 3600 --- +### Osano + +**What it does:** Mirrors Osano's browser IAB API consent signals into standard first-party consent cookies for later Trusted Server requests. + +**Key Features:** + +- Mirrors USP, GPP, and TCF consent signals +- Writes `us_privacy`, `__gpp`, `__gpp_sid`, and `euconsent-v2` +- Marks owned cookies with `_ts_consent_src=osano` +- Preserves unmarked or foreign-owned consent cookies +- Clears stale Osano-owned cookies only after Osano readiness + +**Configuration:** + +```toml +[integrations.osano] +enabled = true +``` + +**Endpoints:** None. Osano v1 only enables the browser consent mirror module. + +**Operational note:** Because mirroring runs in the browser after the page response starts, the first page request cannot include cookies written by the Osano mirror. Server-side consent behavior should rely on mirrored cookies from subsequent requests after Osano APIs are ready. + +**When to use:** You use Osano for consent management and want Osano's browser consent signals available to Trusted Server as standard first-party cookies. + +**Learn more:** [Osano Integration](./integrations/osano.md) + +--- + ### Testlight **What it does:** Testing/development integration for validating the integration system with OpenRTB-like auctions. @@ -236,6 +266,10 @@ Do you use Sourcepoint for consent management? ├─ Yes → Enable Sourcepoint integration └─ No → Skip Sourcepoint +Do you use Osano for consent management? +├─ Yes → Enable Osano integration +└─ No → Skip Osano + Are you developing/testing integrations? ├─ Yes → Enable Testlight integration └─ No → Skip Testlight diff --git a/docs/guide/integrations/osano.md b/docs/guide/integrations/osano.md new file mode 100644 index 00000000..2676db92 --- /dev/null +++ b/docs/guide/integrations/osano.md @@ -0,0 +1,57 @@ +# Osano Integration + +**Category**: CMP (Consent Management Platform) +**Status**: Development +**Type**: Browser Consent Mirror + +## Overview + +The Osano integration mirrors consent signals exposed by Osano's browser IAB APIs into standard first-party consent cookies that Trusted Server can read on later requests. + +The browser module reads: + +- `__uspapi` for US Privacy strings +- `__gpp` for GPP strings and applicable section IDs +- `__tcfapi` for TCF v2 consent strings + +It writes the corresponding first-party cookies when Osano reports ready consent data: + +| Cookie | Source signal | +| ----------------- | ------------------------------- | +| `us_privacy` | USP string from `__uspapi` | +| `__gpp` | GPP string from `__gpp` | +| `__gpp_sid` | GPP applicable section IDs | +| `euconsent-v2` | TCF string from `__tcfapi` | +| `_ts_consent_src` | Ownership marker set to `osano` | + +## Configuration + +Add the following to `trusted-server.toml`: + +```toml +[integrations.osano] +enabled = true +``` + +No additional server-side settings are required for the initial Osano integration. + +## Request Timing Limitation + +Osano consent mirroring runs in the browser after Trusted Server has already served the current page response. That means the first page request cannot include cookies written by this browser-side mirror. + +Server-side behavior that depends on `us_privacy`, `__gpp`, `__gpp_sid`, or `euconsent-v2` should expect those mirrored cookies on subsequent requests after Osano's IAB APIs have become available and the browser module has written the cookies. + +## Cookie Ownership and Clearing + +The Osano mirror uses `_ts_consent_src=osano` to mark cookies it owns. It preserves existing unmarked consent cookies and cookies marked as owned by another mirror. + +When Osano later reports a ready-but-empty consent value, the mirror clears stale Osano-owned cookies only after receiving an Osano readiness event. This avoids deleting previous consent cookies during CMP startup before Osano has finished loading. + +## Endpoints + +The Osano integration does not register Rust proxy endpoints in this version. It only enables the `tsjs-osano` browser module. + +## See Also + +- [Integrations Overview](/guide/integrations-overview) +- [Configuration](/guide/configuration) diff --git a/docs/guide/integrations/sourcepoint.md b/docs/guide/integrations/sourcepoint.md index 3b2e8267..7b9ce943 100644 --- a/docs/guide/integrations/sourcepoint.md +++ b/docs/guide/integrations/sourcepoint.md @@ -26,6 +26,10 @@ cdn_origin = "https://cdn.privacy-mgmt.com" cache_ttl_seconds = 3600 ``` +::: warning Migration note +The Sourcepoint browser module is now opt-in through `[integrations.sourcepoint].enabled = true`. Existing deployments that relied on unconditional Sourcepoint JavaScript inclusion should enable this integration explicitly before upgrading. +::: + ### Configuration Options | Option | Type | Default | Description | diff --git a/docs/superpowers/specs/2026-06-17-osano-consent-mirror-design.md b/docs/superpowers/specs/2026-06-17-osano-consent-mirror-design.md new file mode 100644 index 00000000..64b8a201 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-osano-consent-mirror-design.md @@ -0,0 +1,349 @@ +# Osano Consent Mirror for Edge Cookie Generation + +**Issue:** #772 +**Date:** 2026-06-17 +**Status:** Implemented + +## Problem + +Edge Cookie (EC) generation and withdrawal depend on request-visible consent +signals. Trusted Server currently reads these standard signals from incoming +requests: + +- `euconsent-v2` +- `__gpp` +- `__gpp_sid` +- `us_privacy` +- `Sec-GPC` + +Osano exposes consent in the browser through `window.Osano.cm` and the IAB APIs +(`__tcfapi`, `__uspapi`, `__gpp`). In observed Osano deployments, the CMP stores +its durable consent state in Osano-managed browser storage and a CMP UUID cookie, +but it does not necessarily write the standard cookies Trusted Server reads. + +This creates a transport gap: browser JavaScript can see the user's consent +choice, but the edge request does not carry that choice. In regulated +jurisdictions, Trusted Server correctly fails closed and skips EC creation. That +means EC may remain disabled even after a user accepts permitted storage/identity +use. Conversely, explicit opt-out choices must reach the edge so Trusted Server +can expire an existing EC cookie and write withdrawal tombstones. + +## Goals + +- Add an explicitly-enabled Osano client-side consent mirror modeled after the + existing Sourcepoint mirror pattern. +- Use this work to refactor Sourcepoint so CMP consent mirrors are opt-in via + integration configuration rather than always shipped. +- Translate Osano/IAB browser API output into standard first-party cookies that + Trusted Server already understands. +- Avoid server-side consent-gating changes by reusing the existing consent + extraction and EC gating pipeline. +- Preserve cookies written by another CMP unless Trusted Server knows its Osano + mirror owns them. +- Keep behavior fail-safe: never fabricate consent when Osano or an IAB API is + unavailable or not ready. + +## Non-goals + +- No Rust-side Osano proxy in v1. +- No parsing or reverse-engineering of Osano's opaque `osano_consentmanager` + storage payload. +- No new server-side consent framework or EC gating semantics. +- No changes to how `allows_ec_creation()` interprets TCF, GPP, US Privacy, or + GPC. +- No persistent consent storage in Trusted Server KV. +- No customer-specific configuration, domains, IDs, or test fixtures. + +## Approach + +Build a JS-only `osano` integration that runs in the browser, detects Osano, and +mirrors IAB-compatible consent values into first-party standard cookies for the +next request. + +The first page request after a new consent choice cannot use the mirrored values, +because that request has already reached the edge before browser JavaScript runs. +The mirror enables the next page view, auction, integration request, or other +eligible request to carry the consent state to Trusted Server. + +## Design + +### 1. New JS integration module + +Add a new module: + +```text +crates/js/lib/src/integrations/osano/index.ts +crates/js/lib/test/integrations/osano/index.test.ts +``` + +The module is JS-only but should be explicitly enabled through integration +configuration. Unlike the current Sourcepoint mirror behavior, `osano` should not +be included in the always-shipped JS module list. + +Refactor Sourcepoint as part of this work so consent mirror modules are included +through normal integration/module configuration rather than unconditional +`JS_ALWAYS` behavior. The implementation should preserve Sourcepoint's current +runtime behavior for deployments that enable it, but make that enablement +explicit. + +Suggested runtime behavior: + +1. If `window`/`document` are unavailable, do nothing. +2. If `window.Osano?.cm` is present, register listeners and mirror immediately. +3. If Osano is not present yet, perform bounded retries because TSJS may run + before the CMP script on some pages. +4. On Osano lifecycle events, schedule a debounced mirror attempt. +5. On `visibilitychange` and `focus`, refresh mirrored cookies to avoid stale + session cookies after mid-session consent changes. + +### 2. Osano event hooks + +Use public Osano CMP events exposed through `window.Osano.cm.addEventListener`: + +- `osano-cm-initialized` +- `osano-cm-consent-saved` +- `osano-cm-consent-new` +- `osano-cm-consent-changed` +- `osano-cm-opt-out` +- `osano-cm-storage` + +`osano-cm-consent-saved` is especially important because Osano calls the listener +immediately when consent has already been saved, which covers returning visitors. + +The event callback payload is useful for diagnostics, but the mirror should read +canonical IAB API outputs rather than infer legal signals directly from Osano's +category object. + +### 3. Consent extraction from browser APIs + +Read from IAB APIs when available. + +#### US Privacy + +Call: + +```ts +window.__uspapi('getUSPData', 1, callback) +``` + +If the callback succeeds and returns a non-empty `uspString`, mirror it to: + +```text +us_privacy= +``` + +Observed examples: + +- No sale opt-out: `1YN-` +- Sale opt-out: `1YY-` + +Trusted Server already interprets `us_privacy` in US-state jurisdictions: + +- opt-out sale `Y` blocks EC and can withdraw an existing EC +- opt-out sale `N` allows EC when no stronger opt-out signal is present + +#### GPP + +Call: + +```ts +window.__gpp('ping', callback) +``` + +Only mirror GPP when: + +- callback succeeds +- `signalStatus === 'ready'` +- `gppString` is non-empty + +Write: + +```text +__gpp= +__gpp_sid= +``` + +Do not write `__gpp_sid` when `applicableSections` is empty or `[-1]`. + +This is primarily pass-through for downstream compatibility and for deployments +where Osano includes TCF/GPP sections. For US Privacy section 6, the Osano mirror +also writes `us_privacy`, so no server-side GPP USP decoding is required for EC +in v1. + +#### TCF + +Call: + +```ts +window.__tcfapi('getTCData', 2, callback) +``` + +If the callback succeeds and returns a non-empty `tcString`, mirror it to: + +```text +euconsent-v2= +``` + +Do not synthesize a TC string from Osano's `STORAGE` category. For GDPR/TCF, +Trusted Server should continue to rely on a real TC string and existing TCF +Purpose 1 decoding. + +### 4. Cookie ownership marker + +The Osano mirror must not clobber consent cookies written by another CMP or +publisher script. Use a single source marker cookie to track Trusted +Server-owned Osano writes: + +```text +_ts_consent_src=osano +``` + +Ownership rules: + +- If all target standard consent cookies are absent, the Osano mirror may write + mirrored values and set `_ts_consent_src=osano`. +- If `_ts_consent_src=osano`, the Osano mirror may update or clear the standard + consent cookies it manages: `us_privacy`, `__gpp`, `__gpp_sid`, and + `euconsent-v2`. +- If `_ts_consent_src` exists and is not `osano`, preserve existing standard + consent cookies and log a debug message. +- If any target standard consent cookie exists but `_ts_consent_src` is absent, + preserve existing cookies and log a debug message. This protects unknown + external writers. +- Clear stale Osano-owned cookies only after Osano is initialized and the + relevant IAB API definitively reports no usable value. Do not clear cookies + merely because Osano has not loaded yet. + +This intentionally assumes one active CMP integration per site. Multiple active +CMPs on a single publisher site are treated as an unsupported/misconfigured edge +case for v1. A future version can split this into per-signal ownership markers if +real deployments need mixed ownership of `us_privacy`, `__gpp`, and +`euconsent-v2`. + +### 5. Cookie attributes + +Write mirrored cookies as session cookies: + +```text +Path=/; Secure; SameSite=Lax +``` + +Use session scope because Osano remains the source of truth. The integration +re-mirrors on page load, consent events, focus, and visibility refresh. + +Cookie values should be written raw, not URL-encoded, because Trusted Server's +server-side decoders expect the standard string values as-is. + +### 6. Runtime sequencing + +Recommended mirror loop: + +1. `initializeOsanoConsentMirror()` guards against double initialization. +2. Install Osano listeners once Osano is present. +3. Schedule `mirrorOsanoConsent()` with a short debounce after relevant events. +4. `mirrorOsanoConsent()` attempts USP, GPP, and TCF independently. +5. API reads are independent so a ready USP value can still mirror while GPP or + TCF is unavailable. Cookie writes use the shared `_ts_consent_src` ownership + marker described above. +6. If an API is missing or not ready, leave existing cookies alone. Startup + retries are bounded to Osano listener discovery; focus and visibility events + also refresh the mirror later in the session. + +Independent API reads matter because Osano may expose US Privacy before GPP is +ready, or TCF only in GDPR jurisdictions. + +## Files touched + +| File | Change | +| --------------------------------------------------------- | ------------------------------------------------------------------------- | +| `crates/js/lib/src/integrations/osano/index.ts` | New JS-only Osano consent mirror | +| `crates/js/lib/test/integrations/osano/index.test.ts` | New Vitest coverage for mirroring and ownership rules | +| `crates/trusted-server-core/src/integrations/osano.rs` | New minimal integration config/registration for explicit JS enablement | +| `crates/trusted-server-core/src/integrations/registry.rs` | Remove consent mirrors from unconditional `JS_ALWAYS`; include via config | +| `trusted-server.toml` | Document disabled-by-default Osano integration block | +| `crates/js/lib/src/integrations/*` build output | Generated bundle output changes via existing JS build pipeline | + +No changes are expected in Rust consent decoding or EC gating. + +## Testing + +### JS unit tests + +Add Vitest tests for: + +- no-op when Osano is unavailable +- bounded retry when Osano appears after TSJS initialization +- mirrors `us_privacy` from successful `__uspapi('getUSPData')` +- mirrors opt-out `us_privacy` values without changing semantics +- mirrors `__gpp` and `__gpp_sid` only when GPP `signalStatus` is `ready` +- does not mirror GPP while signal status is `not ready` +- mirrors `euconsent-v2` from successful `__tcfapi('getTCData')` +- preserves pre-existing cookies when Osano marker is absent +- updates/clears cookies when the corresponding Osano marker is present +- refreshes mirrored cookies on `osano-cm-consent-saved` and returning consent +- handles callback failure, timeout, malformed callback payloads, and missing APIs + +Use example values only. Do not use real publisher domains, customer IDs, or +production CMP configuration IDs in tests. + +### Local/manual verification + +Use a local HTML fixture or controlled test page that stubs Osano/IAB APIs: + +1. Start `fastly compute serve`. +2. Load a page with the Osano mirror bundle and stubbed APIs. +3. Trigger an accept-like USP response (`1YN-`). +4. Verify the browser writes `us_privacy=1YN-` and `_ts_consent_src=osano`. +5. Make a subsequent request and confirm EC gating sees the consent cookie. +6. Trigger an opt-out-like USP response (`1YY-`). +7. Verify the browser updates `us_privacy=1YY-` and subsequent requests block or + withdraw EC according to existing server behavior. + +### Existing checks + +Run when implementing: + +```bash +cd crates/js/lib && npx vitest run +cd crates/js/lib && node build-all.mjs +cargo test --workspace +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +## Risks and mitigations + +- **First-request limitation:** mirrored cookies are only available after browser + JavaScript runs. Mitigation: document that EC starts on subsequent eligible + requests after consent. +- **Multiple CMPs:** another CMP may own standard cookies. Mitigation: a single + marker cookie plus preserve-by-default behavior for unknown external writers. +- **API readiness races:** GPP may be `not ready` while USP is available. + Mitigation: handle signals independently and retry boundedly. +- **Over-broad consent inference:** Osano categories are not equivalent to full + IAB strings. Mitigation: mirror only real IAB API outputs. +- **Stale mirrored cookies:** session cookies may outlive in-memory API state for + a tab. Mitigation: refresh on load, focus, visibility, and consent events; + clear only Osano-owned stale values after Osano is initialized. + +## Review decisions and open questions + +1. **Decision:** Osano must be explicitly enabled. Use this work to refactor the + Sourcepoint consent mirror so CMP consent mirrors are opt-in through normal + integration configuration rather than always shipped. +2. **Decision:** Use one shared marker cookie, `_ts_consent_src=osano`, for v1. +3. **Decision:** Follow existing TSJS logging behavior only. Do not add a new + Osano-specific debug logging flag. + +### Marker-cookie strategy rationale + +The mirror writes standard cookies such as `us_privacy`, `__gpp`, `__gpp_sid`, +and `euconsent-v2`. Those names are not Osano-specific; another CMP or publisher +script could already be writing them. The marker cookie tells Trusted Server's JS +mirror whether it "owns" the standard consent cookies and is allowed to update or +clear them. + +Use a single marker for v1 because the expected deployment model is one active +CMP per publisher site. That keeps the implementation simple, reduces cookie +count, and makes debugging easier. If a target cookie exists without the Osano +marker, preserve it rather than guessing ownership. diff --git a/trusted-server.toml b/trusted-server.toml index e0301efa..f669dd34 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -118,6 +118,9 @@ cdn_origin = "https://cdn.privacy-mgmt.com" # auth_cookie_name = "sp_auth" cache_ttl_seconds = 3600 +[integrations.osano] +enabled = false + [integrations.permutive] enabled = false organization_id = ""