diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index cc5690e0..b1a650b8 100644 --- a/crates/js/lib/build-all.mjs +++ b/crates/js/lib/build-all.mjs @@ -13,6 +13,12 @@ * names to include in the bundle (e.g. "rubicon,appnexus,openx"). * Each name must have a corresponding {name}BidAdapter.js module in * the prebid.js package. Default: "rubicon". + * TSJS_PREBID_USER_IDS — Comma-separated list of Prebid.js User ID + * submodule filenames (no `.js` extension) to include in the bundle + * (e.g. "sharedIdSystem,id5IdSystem,criteoIdSystem"). The `userId.js` + * core module is always included and is not configurable here. + * Default: the full ship-set of 13 submodules (see + * DEFAULT_PREBID_USER_IDS below). */ import fs from 'node:fs'; @@ -107,6 +113,132 @@ function generatePrebidAdapters() { generatePrebidAdapters(); +// --------------------------------------------------------------------------- +// Prebid User ID submodule generation +// --------------------------------------------------------------------------- + +/** + * Default set of Prebid User ID submodules bundled when TSJS_PREBID_USER_IDS + * is unset. Matches the set originally hardcoded in index.ts when User ID + * support first shipped. `userId.js` (the core module) is imported + * unconditionally by index.ts and is not in this list. + */ +const DEFAULT_PREBID_USER_IDS = [ + 'sharedIdSystem', + 'criteoIdSystem', + '33acrossIdSystem', + 'pubProvidedIdSystem', + 'quantcastIdSystem', + 'id5IdSystem', + 'identityLinkIdSystem', + 'uid2IdSystem', + 'euidIdSystem', + 'intentIqIdSystem', + 'lotamePanoramaIdSystem', + 'connectIdSystem', + 'merkleIdSystem', +].join(','); + +/** + * Modules known to be incompatible with the current esbuild pipeline. + * + * `liveIntentIdSystem` uses a dynamic `require()` inside a build-flag-guarded + * branch that Prebid's own gulp pipeline dead-codes via constant folding. + * esbuild leaves the `require()` in the bundle, which throws + * `ReferenceError: require is not defined` at browser runtime. See spec + * follow-up #4 in `docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md`. + */ +const PREBID_USER_ID_DENYLIST = new Set(['liveIntentIdSystem']); + +const USER_IDS_FILE = path.join( + integrationsDir, + 'prebid', + '_user_ids.generated.ts', +); + +/** + * Generate `_user_ids.generated.ts` with import statements for each User ID + * submodule listed in the TSJS_PREBID_USER_IDS environment variable. + * + * Invalid submodule names (those without a matching module in prebid.js) or + * known-broken modules in the denylist are logged and skipped. + */ +function generatePrebidUserIds() { + const raw = process.env.TSJS_PREBID_USER_IDS || DEFAULT_PREBID_USER_IDS; + const names = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + if (names.length === 0) { + console.warn( + '[build-all] TSJS_PREBID_USER_IDS is empty, falling back to default set', + ); + names.push(...DEFAULT_PREBID_USER_IDS.split(',')); + } + + const modulesDir = path.join( + __dirname, + 'node_modules', + 'prebid.js', + 'modules', + ); + + const imports = []; + const includedNames = []; + for (const name of names) { + if (PREBID_USER_ID_DENYLIST.has(name)) { + console.error( + `[build-all] WARNING: Prebid User ID submodule "${name}" is on the ` + + `esbuild-incompatibility denylist and will not be bundled. See ` + + `docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md ` + + `follow-up #4.`, + ); + continue; + } + const moduleFile = `${name}.js`; + const modulePath = path.join(modulesDir, moduleFile); + // Some modules ship as .ts in modules/ but resolve via the exports map + // to dist/src/public/*.js (e.g. sharedIdSystem). Accept either form. + const distPath = path.join(__dirname, 'node_modules', 'prebid.js', 'dist', 'src', 'public', moduleFile); + if (!fs.existsSync(modulePath) && !fs.existsSync(distPath)) { + console.error( + `[build-all] WARNING: Prebid User ID submodule "${name}" not found (expected ${moduleFile}), skipping`, + ); + continue; + } + imports.push(`import 'prebid.js/modules/${moduleFile}';`); + includedNames.push(name); + } + + if (imports.length === 0) { + console.error( + '[build-all] WARNING: No valid Prebid User ID submodules found, ' + + 'bundle will resolve no EIDs even if publisher configures userSync.userIds', + ); + } + + const content = [ + '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', + '//', + '// Controls which Prebid.js User ID submodules are included in the bundle.', + '// Set the TSJS_PREBID_USER_IDS environment variable to a comma-separated', + '// list of submodule filenames without the `.js` extension', + '// (e.g. "sharedIdSystem,id5IdSystem,criteoIdSystem") before building.', + '// The userId.js core module is always included via a static import in', + '// index.ts and is not configurable here.', + '', + ...imports, + '', + ].join('\n'); + + fs.writeFileSync(USER_IDS_FILE, content); + + console.log('[build-all] Prebid User ID submodules:', includedNames); +} + +generatePrebidUserIds(); + // --------------------------------------------------------------------------- // Clean dist directory diff --git a/crates/js/lib/src/core/render.ts b/crates/js/lib/src/core/render.ts index ee08ef28..da223851 100644 --- a/crates/js/lib/src/core/render.ts +++ b/crates/js/lib/src/core/render.ts @@ -7,16 +7,15 @@ import NORMALIZE_CSS from './styles/normalize.css?inline'; import IFRAME_TEMPLATE from './templates/iframe.html?raw'; // Sandbox permissions granted to creative iframes. -// Ad creatives routinely contain scripts for tracking, click handling, and -// viewability measurement, so allow-scripts and allow-same-origin are required -// for creatives to render correctly. Server-side sanitization is the primary -// defense against malicious markup; the sandbox provides defense-in-depth. +// Notably absent: +// allow-scripts, allow-same-origin — prevent JS execution and same-origin +// access, which are the primary attack vectors for malicious creatives. +// allow-forms — server-side sanitization strips
elements, so form +// submission from creatives is not a supported use case. Omitting this token +// is consistent with that server-side policy and reduces the attack surface. const CREATIVE_SANDBOX_TOKENS = [ - 'allow-forms', 'allow-popups', 'allow-popups-to-escape-sandbox', - 'allow-same-origin', - 'allow-scripts', 'allow-top-navigation-by-user-activation', ] as const; diff --git a/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts new file mode 100644 index 00000000..9eb586dd --- /dev/null +++ b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts @@ -0,0 +1,22 @@ +// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// +// Controls which Prebid.js User ID submodules are included in the bundle. +// Set the TSJS_PREBID_USER_IDS environment variable to a comma-separated +// list of submodule filenames without the `.js` extension +// (e.g. "sharedIdSystem,id5IdSystem,criteoIdSystem") before building. +// The userId.js core module is always included via a static import in +// index.ts and is not configurable here. + +import 'prebid.js/modules/sharedIdSystem.js'; +import 'prebid.js/modules/criteoIdSystem.js'; +import 'prebid.js/modules/33acrossIdSystem.js'; +import 'prebid.js/modules/pubProvidedIdSystem.js'; +import 'prebid.js/modules/quantcastIdSystem.js'; +import 'prebid.js/modules/id5IdSystem.js'; +import 'prebid.js/modules/identityLinkIdSystem.js'; +import 'prebid.js/modules/uid2IdSystem.js'; +import 'prebid.js/modules/euidIdSystem.js'; +import 'prebid.js/modules/intentIqIdSystem.js'; +import 'prebid.js/modules/lotamePanoramaIdSystem.js'; +import 'prebid.js/modules/connectIdSystem.js'; +import 'prebid.js/modules/merkleIdSystem.js'; diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index fc976334..0f49fdb6 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -17,6 +17,18 @@ import 'prebid.js/modules/consentManagementTcf.js'; import 'prebid.js/modules/consentManagementGpp.js'; import 'prebid.js/modules/consentManagementUsp.js'; +// Prebid User ID Module core — always bundled. Exposes +// `pbjs.getUserIdsAsEids` and registers the submodule machinery that each +// ID submodule in `_user_ids.generated.ts` hooks into. ID submodules +// activate only when the publisher's origin-side `pbjs.setConfig({ +// userSync: { userIds: [...] } })` call runs during `processQueue()`. +import 'prebid.js/modules/userId.js'; + +// Prebid User ID submodules — self-register with the core on import. +// The set of submodules is controlled by the TSJS_PREBID_USER_IDS env var +// at build time. See _user_ids.generated.ts (written by build-all.mjs). +import './_user_ids.generated'; + // Client-side bid adapters — self-register with prebid.js on import. // The set of adapters is controlled by the TSJS_PREBID_ADAPTERS env var at // build time. See _adapters.generated.ts (written by build-all.mjs). @@ -355,6 +367,10 @@ const EID_COOKIE_NAME = 'ts-eids'; /** Cookie max-age in seconds (1 day). */ const EID_COOKIE_MAX_AGE = 86400; +function clearPrebidEidsCookie(): void { + document.cookie = `${EID_COOKIE_NAME}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; +} + interface PrebidEid { source: string; uids?: Array<{ id: string; atype?: number }>; @@ -373,11 +389,13 @@ interface FlatEid { function syncPrebidEidsCookie(): void { try { if (typeof pbjs.getUserIdsAsEids !== 'function') { + clearPrebidEidsCookie(); return; } const rawEids: PrebidEid[] = pbjs.getUserIdsAsEids() ?? []; if (rawEids.length === 0) { + clearPrebidEidsCookie(); return; } @@ -395,6 +413,7 @@ function syncPrebidEidsCookie(): void { } if (flat.length === 0) { + clearPrebidEidsCookie(); return; } @@ -407,11 +426,11 @@ function syncPrebidEidsCookie(): void { } if (encoded.length > MAX_EID_COOKIE_BYTES) { + clearPrebidEidsCookie(); return; // Single EID too large — skip. } - document.cookie = - `${EID_COOKIE_NAME}=${encoded}; Path=/; Secure; SameSite=Lax; Max-Age=${EID_COOKIE_MAX_AGE}`; + document.cookie = `${EID_COOKIE_NAME}=${encoded}; Path=/; Secure; SameSite=Lax; Max-Age=${EID_COOKIE_MAX_AGE}`; log.debug(`[tsjs-prebid] synced ${payload.length} EIDs to cookie`); } catch (err) { 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..e7024b62 --- /dev/null +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -0,0 +1,93 @@ +import { log } from '../../core/log'; + +const SP_CONSENT_PREFIX = '_sp_user_consent_'; +const GPP_COOKIE_NAME = '__gpp'; +const GPP_SID_COOKIE_NAME = '__gpp_sid'; + +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 { + 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; +} + +const GPP_COOKIE_MAX_AGE = 86400; + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax; Max-Age=${GPP_COOKIE_MAX_AGE}`; +} + +function clearCookie(name: string): void { + document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; +} + +/** + * 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) { + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + + const { gppString, applicableSections } = payload.gppData; + if (!gppString) { + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + log.debug('sourcepoint: gppString is empty'); + return false; + } + + 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); + } + + log.info('sourcepoint: mirrored GPP consent to cookies', { + gppLength: gppString.length, + sections: applicableSections, + }); + + return true; +} + +if (typeof window !== 'undefined') { + mirrorSourcepointConsent(); +} + +export default mirrorSourcepointConsent; diff --git a/crates/js/lib/test/core/render.test.ts b/crates/js/lib/test/core/render.test.ts index a81486cf..5bdb3a81 100644 --- a/crates/js/lib/test/core/render.test.ts +++ b/crates/js/lib/test/core/render.test.ts @@ -27,12 +27,12 @@ describe('render', () => { expect(iframe.srcdoc).toContain('ad'); expect(div.querySelector('iframe')).toBe(iframe); const sandbox = iframe.getAttribute('sandbox') ?? ''; - expect(sandbox).toContain('allow-forms'); + expect(sandbox).not.toContain('allow-forms'); expect(sandbox).toContain('allow-popups'); expect(sandbox).toContain('allow-popups-to-escape-sandbox'); expect(sandbox).toContain('allow-top-navigation-by-user-activation'); - expect(sandbox).toContain('allow-same-origin'); - expect(sandbox).toContain('allow-scripts'); + expect(sandbox).not.toContain('allow-same-origin'); + expect(sandbox).not.toContain('allow-scripts'); }); it('preserves dollar sequences when building the creative document', async () => { diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 7f18148d..0408b66b 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Define mocks using vi.hoisted so they're available inside vi.mock factories @@ -6,6 +9,7 @@ const { mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, + mockGetUserIdsAsEids, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -15,11 +19,15 @@ const { const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); const mockGetBidAdapter = vi.fn(); + const mockGetUserIdsAsEids = vi.fn( + () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> + ); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, + getUserIdsAsEids: mockGetUserIdsAsEids, adUnits: [] as any[], }; const mockAdapterManager = { @@ -30,6 +38,7 @@ const { mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, + mockGetUserIdsAsEids, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -46,8 +55,13 @@ vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); -// Mock the build-generated adapter imports (no-op in tests) +// User ID Module core — no-op mock so jsdom does not try to execute the +// real Prebid code paths. +vi.mock('prebid.js/modules/userId.js', () => ({})); + +// Mock the build-generated adapter and User ID submodule imports (no-op in tests) vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); +vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({})); import { collectBidders, @@ -805,3 +819,277 @@ describe('prebid/client-side bidders', () => { errorSpy.mockRestore(); }); }); + +describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + mockGetUserIdsAsEids.mockReset(); + mockGetUserIdsAsEids.mockReturnValue([]); + // Restore the pbjs→mock wiring in case a prior test blanked it out. + (mockPbjs as any).getUserIdsAsEids = mockGetUserIdsAsEids; + delete (window as any).__tsjs_prebid; + // Wipe any leftover ts-eids cookie from previous tests. + document.cookie = 'ts-eids=; Path=/; Max-Age=0'; + }); + + afterEach(() => { + document.cookie = 'ts-eids=; Path=/; Max-Age=0'; + }); + + /** + * Helper: make mockRequestBids actually invoke the injected bidsBackHandler + * so the shim's post-auction sync path runs. + */ + function wireBidsBackHandler(): void { + mockRequestBids.mockImplementation((opts: any) => { + if (typeof opts?.bidsBackHandler === 'function') { + opts.bidsBackHandler(); + } + }); + } + + function getTsEidsCookie(): string | undefined { + const match = document.cookie.split('; ').find((c) => c.startsWith('ts-eids=')); + return match ? match.split('=').slice(1).join('=') : undefined; + } + + it('writes no cookie when getUserIdsAsEids returns empty array', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + mockGetUserIdsAsEids.mockReturnValue([]); + + pbjs.requestBids({ adUnits: [] } as any); + + expect(getTsEidsCookie()).toBeUndefined(); + }); + + it('clears an existing cookie when getUserIdsAsEids returns empty array', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + document.cookie = 'ts-eids=stale; Path=/'; + mockGetUserIdsAsEids.mockReturnValue([]); + + pbjs.requestBids({ adUnits: [] } as any); + + expect(getTsEidsCookie()).toBeUndefined(); + }); + + it('writes ts-eids cookie with base64-encoded flat JSON for normal payload', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + mockGetUserIdsAsEids.mockReturnValue([ + { source: 'sharedid.org', uids: [{ id: 'shared-abc', atype: 1 }] }, + { source: 'id5-sync.com', uids: [{ id: 'id5-xyz', atype: 3 }] }, + ]); + + pbjs.requestBids({ adUnits: [] } as any); + + const encoded = getTsEidsCookie(); + expect(encoded).toBeDefined(); + const decoded = JSON.parse(atob(encoded!)); + expect(decoded).toEqual([ + { source: 'sharedid.org', id: 'shared-abc', atype: 1 }, + { source: 'id5-sync.com', id: 'id5-xyz', atype: 3 }, + ]); + }); + + it('defaults atype to 3 when the uid omits it', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + mockGetUserIdsAsEids.mockReturnValue([{ source: 'example.com', uids: [{ id: 'no-atype' }] }]); + + pbjs.requestBids({ adUnits: [] } as any); + + const decoded = JSON.parse(atob(getTsEidsCookie()!)); + expect(decoded).toEqual([{ source: 'example.com', id: 'no-atype', atype: 3 }]); + }); + + it('skips EID entries that are missing id or source', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + mockGetUserIdsAsEids.mockReturnValue([ + { source: 'good.example', uids: [{ id: 'keep', atype: 1 }] }, + { source: 'empty-uids.example', uids: [] }, + { source: '', uids: [{ id: 'no-source', atype: 1 }] }, + { source: 'no-id.example', uids: [{ id: '', atype: 1 }] }, + ]); + + pbjs.requestBids({ adUnits: [] } as any); + + const decoded = JSON.parse(atob(getTsEidsCookie()!)); + expect(decoded).toEqual([{ source: 'good.example', id: 'keep', atype: 1 }]); + }); + + it('takes the first uid per source when multiple are present', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'multi.example', + uids: [ + { id: 'first', atype: 1 }, + { id: 'second', atype: 2 }, + ], + }, + ]); + + pbjs.requestBids({ adUnits: [] } as any); + + const decoded = JSON.parse(atob(getTsEidsCookie()!)); + expect(decoded).toEqual([{ source: 'multi.example', id: 'first', atype: 1 }]); + }); + + it('trims EIDs from the tail when the cookie payload would exceed 3072 bytes', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + + // Build ~20 entries each ~200 bytes → definitely exceeds 3072-byte cap + // once base64-encoded. + const big = Array.from({ length: 20 }, (_, i) => ({ + source: `source-${i}.example`, + uids: [{ id: 'x'.repeat(200) + String(i), atype: 3 }], + })); + mockGetUserIdsAsEids.mockReturnValue(big); + + pbjs.requestBids({ adUnits: [] } as any); + + const encoded = getTsEidsCookie(); + expect(encoded).toBeDefined(); + expect(encoded!.length).toBeLessThanOrEqual(3072); + + const decoded = JSON.parse(atob(encoded!)); + // At least one entry kept, strictly fewer than original count. + expect(decoded.length).toBeGreaterThan(0); + expect(decoded.length).toBeLessThan(big.length); + // Head of the list is preserved (trimming happens from the tail). + expect(decoded[0].source).toBe('source-0.example'); + }); + + it('writes no cookie when a single entry alone exceeds the cap', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + + // Single entry large enough to blow past 3072 bytes after base64. + mockGetUserIdsAsEids.mockReturnValue([ + { source: 'too-big.example', uids: [{ id: 'x'.repeat(4000), atype: 3 }] }, + ]); + + pbjs.requestBids({ adUnits: [] } as any); + + expect(getTsEidsCookie()).toBeUndefined(); + }); + + it('clears an existing cookie when flattening yields no valid EIDs', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + document.cookie = 'ts-eids=stale; Path=/'; + mockGetUserIdsAsEids.mockReturnValue([ + { source: 'missing-id.example', uids: [{ id: '', atype: 3 }] }, + { source: '', uids: [{ id: 'missing-source', atype: 3 }] }, + { source: 'empty-uids.example', uids: [] }, + ]); + + pbjs.requestBids({ adUnits: [] } as any); + + expect(getTsEidsCookie()).toBeUndefined(); + }); + + it('clears an existing cookie when a single oversized entry cannot be written', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + document.cookie = 'ts-eids=stale; Path=/'; + mockGetUserIdsAsEids.mockReturnValue([ + { source: 'too-big.example', uids: [{ id: 'x'.repeat(4000), atype: 3 }] }, + ]); + + pbjs.requestBids({ adUnits: [] } as any); + + expect(getTsEidsCookie()).toBeUndefined(); + }); + + it('does not throw when getUserIdsAsEids is undefined (pre-fix production state)', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + // Simulate a build that forgot the userId core module. + (mockPbjs as any).getUserIdsAsEids = undefined; + + expect(() => pbjs.requestBids({ adUnits: [] } as any)).not.toThrow(); + expect(getTsEidsCookie()).toBeUndefined(); + + // Restore for subsequent tests. + (mockPbjs as any).getUserIdsAsEids = mockGetUserIdsAsEids; + }); + + it('clears an existing cookie when getUserIdsAsEids is undefined', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + document.cookie = 'ts-eids=stale; Path=/'; + (mockPbjs as any).getUserIdsAsEids = undefined; + + pbjs.requestBids({ adUnits: [] } as any); + + expect(getTsEidsCookie()).toBeUndefined(); + + // Restore for subsequent tests. + (mockPbjs as any).getUserIdsAsEids = mockGetUserIdsAsEids; + }); + + it('calls the original bidsBackHandler after syncing EIDs', () => { + wireBidsBackHandler(); + const pbjs = installPrebidNpm(); + const originalHandler = vi.fn(); + + pbjs.requestBids({ adUnits: [], bidsBackHandler: originalHandler } as any); + + expect(originalHandler).toHaveBeenCalledTimes(1); + }); +}); + +describe('prebid User ID Module imports (regression guard)', () => { + // `userId.js` is the core module — bundled unconditionally via a static + // import in index.ts, never operator-configurable. Guard it there. + const INDEX_PATH = resolve(process.cwd(), 'src/integrations/prebid/index.ts'); + const indexSource = readFileSync(INDEX_PATH, 'utf8'); + + it('index.ts statically imports the User ID core module', () => { + expect(indexSource).toMatch(/import\s+['"]prebid\.js\/modules\/userId\.js['"]/); + }); + + it('index.ts statically imports the generated User ID submodule file', () => { + expect(indexSource).toMatch(/import\s+['"]\.\/_user_ids\.generated['"]/); + }); + + // The submodule list is operator-controlled via TSJS_PREBID_USER_IDS, but + // the default ship-set must keep resolving without env var action. Read + // the generated file produced by `node build-all.mjs` with no env override + // and assert every default submodule is imported. If this file is missing, + // the developer has not yet run the build — skip with a clear message. + const GENERATED_PATH = resolve(process.cwd(), 'src/integrations/prebid/_user_ids.generated.ts'); + const DEFAULT_SUBMODULES = [ + 'sharedIdSystem', + 'criteoIdSystem', + '33acrossIdSystem', + 'pubProvidedIdSystem', + 'quantcastIdSystem', + 'id5IdSystem', + 'identityLinkIdSystem', + 'uid2IdSystem', + 'euidIdSystem', + 'intentIqIdSystem', + 'lotamePanoramaIdSystem', + 'connectIdSystem', + 'merkleIdSystem', + ]; + + for (const name of DEFAULT_SUBMODULES) { + it(`_user_ids.generated.ts imports ${name}.js by default`, () => { + const generated = readFileSync(GENERATED_PATH, 'utf8'); + const pattern = new RegExp( + `import\\s+['"]prebid\\.js/modules/${name.replace(/\./g, '\\.')}\\.js['"]` + ); + expect(generated).toMatch(pattern); + }); + } +}); 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..872585c3 --- /dev/null +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; + +describe('integrations/sourcepoint', () => { + function clearAllCookies(): void { + 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=/`; + }); + } + + 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(() => { + clearAllCookies(); + 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('clears stale mirrored cookies when no valid Sourcepoint payload exists', () => { + document.cookie = '__gpp=stale-gpp; path=/'; + document.cookie = '__gpp_sid=7,8; path=/'; + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(getCookie('__gpp')).toBeUndefined(); + expect(getCookie('__gpp_sid')).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({ + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [7], + }, + }) + ); + + 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', () => { + 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='); + }); + + it('clears stale __gpp_sid when the payload has no applicable sections', () => { + document.cookie = '__gpp_sid=7,8; path=/'; + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify({ + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [], + }, + }) + ); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); + expect(getCookie('__gpp_sid')).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index 9d0e5c81..cb704b02 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -72,10 +72,13 @@ 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 saw_not_opted_out = false; + + 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) => saw_not_opted_out = true, + None => {} + }, + Err(e) => { + log::warn!("Failed to decode US GPP section {us_section_id}: {e}"); + } + } + } + + if saw_not_opted_out { + Some(false) + } else { + None + } +} + +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; + + 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 +319,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 aed71281..7f77deb3 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -507,6 +507,12 @@ pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { 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; @@ -721,6 +727,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() } @@ -884,6 +891,7 @@ mod tests { version: 1, section_ids: vec![2], eu_tcf: Some(make_tcf(0, true)), + us_sale_opt_out: None, }), ..ConsentContext::default() }; @@ -966,6 +974,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() @@ -1159,4 +1168,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/cookies.rs b/crates/trusted-server-core/src/cookies.rs index ca94a32c..3408eb79 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -145,7 +145,7 @@ mod tests { } #[test] - fn test_parse_cookies_to_jar_empty() { + fn test_parse_cookies_to_jar_emtpy() { let cookie_str = ""; let jar = parse_cookies_to_jar(cookie_str); diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index 245af0e1..45162d8c 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -329,7 +329,7 @@ fn is_safe_data_uri(lower: &str) -> bool { /// Strip dangerous elements and attributes from ad creative HTML. /// -/// Removes elements that can execute code or exfiltrate data (`script`, +/// Removes elements that can execute code or exfiltrate data (`script`, `iframe`, /// `object`, `embed`, `base`, `meta`, `form`, `link`, `style`, `noscript`) and strips `on*` event-handler /// attributes and dangerous URI schemes from all remaining elements: /// - `javascript:`, `vbscript:` @@ -361,17 +361,18 @@ pub fn sanitize_creative_html(markup: &str) -> String { HtmlSettings { element_content_handlers: vec![ // Remove executable/dangerous elements along with their inner content. - // -