From 188fd03cdd3329f577a9411532448cdf9b2df3f2 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Apr 2026 15:44:08 -0500 Subject: [PATCH 01/22] Add design spec for Sourcepoint GPP consent support (#640) --- ...26-04-15-sourcepoint-gpp-consent-design.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md diff --git a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md new file mode 100644 index 000000000..ed72f3f55 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md @@ -0,0 +1,151 @@ +# Sourcepoint GPP Consent for Edge Cookie Generation + +**Issue:** #640 +**Date:** 2026-04-15 +**Status:** Approved + +## Problem + +Edge Cookie (EC) generation fails for sites using Sourcepoint when consent is +stored only in `localStorage` and not surfaced via the standard cookies Trusted +Server reads. Sourcepoint stores US consent under `_sp_user_consent_*` keys in +`localStorage`, including a full GPP string and applicable section IDs. + +Today, Trusted Server only reads consent from `euconsent-v2`, `__gpp`, +`__gpp_sid`, `us_privacy` cookies and the `Sec-GPC` header. Even if `__gpp` / +`__gpp_sid` were present, the server only decodes the EU TCF v2 section from +GPP — it does not use GPP US sections as a consent signal for EC gating. + +This creates two gaps: + +1. **Transport gap:** The server cannot read browser `localStorage`, so no + consent reaches the backend unless client code mirrors it into cookies. +2. **Semantics gap:** Even with `__gpp` / `__gpp_sid` cookies present, current + US-state EC gating does not recognize GPP US sections as valid consent. + +## Approach + +Thin GPP pass-through: mirror Sourcepoint localStorage consent into standard +cookies on the client, and extend server-side EC gating to recognize GPP US +`sale_opt_out` as a consent signal. No compatibility bridge (`us_privacy` +derivation) — both client and server changes ship together. + +## Design + +### 1. Client-side: Sourcepoint JS integration + +New JS-only integration at `crates/js/lib/src/integrations/sourcepoint/index.ts`. +No Rust-side `IntegrationRegistration` (same pattern as `creative`). + +**On page load:** + +1. Scan `localStorage` keys matching `_sp_user_consent_*`. +2. Take the first match, parse the JSON value. +3. Extract `gppData.gppString` and `gppData.applicableSections` from the payload. +4. Write first-party cookies: + - `__gpp=` (path `/`, `SameSite=Lax`) + - `__gpp_sid=` (path `/`, `SameSite=Lax`) +5. Log what was written for debugging. + +Cookies are session-scoped (no `max-age` / `expires`) since the source of truth +stays in `localStorage` and we re-mirror on each page load. The integration runs +once — no polling or event listeners. + +### 2. Server-side: GPP US section decoding + +**`crates/trusted-server-core/src/consent/types.rs`** — extend `GppConsent`: + +```rust +pub struct GppConsent { + pub version: u8, + pub section_ids: Vec, + pub eu_tcf: Option, + pub us_sale_opt_out: Option, // new +} +``` + +- `Some(true)` — a US section is present and `sale_opt_out == OptedOut` +- `Some(false)` — a US section is present and `sale_opt_out != OptedOut` +- `None` — no US section exists in the GPP string + +**`crates/trusted-server-core/src/consent/gpp.rs`** — add `decode_us_sale_opt_out`: + +Checks for any US section ID (7–23) in the parsed `GPPString`. For the first +match, decodes the section via `iab_gpp` and extracts `sale_opt_out`. Maps +`OptOut::OptedOut` to `true`, everything else to `false`. + +The `iab_gpp` crate uses different structs per state (`UsNat`, `UsCa`, `UsTn`, +etc.) but they all have `sale_opt_out: OptOut` via `us_common`. We match on the +decoded `Section` enum to extract it. + +### 3. Server-side: EC gating update + +**`crates/trusted-server-core/src/consent/mod.rs`** — update `allows_ec_creation()` +for `Jurisdiction::UsState(_)`. + +New precedence chain: + +``` +GPC → TCF → GPP US sale_opt_out → us_privacy → fail-closed +``` + +Insert between the existing TCF and `us_privacy` branches: + +```rust +// Check GPP US section for sale opt-out. +if let Some(gpp) = &ctx.gpp { + if let Some(opted_out) = gpp.us_sale_opt_out { + return !opted_out; + } +} +``` + +Semantics: + +- GPP US `sale_opt_out != OptedOut` → EC allowed +- GPP US `sale_opt_out == OptedOut` → EC blocked +- No GPP US section → falls through to `us_privacy` +- GPC still short-circuits at the top (unchanged) +- TCF still takes priority for CMPs like Didomi (unchanged) + +### 4. Files touched + +| File | Change | +|---|---| +| `crates/js/lib/src/integrations/sourcepoint/index.ts` | New — localStorage auto-discovery, cookie mirroring | +| `crates/js/lib/src/integrations/sourcepoint/index.test.ts` | New — Vitest tests | +| `crates/trusted-server-core/src/consent/types.rs` | Add `us_sale_opt_out: Option` to `GppConsent` | +| `crates/trusted-server-core/src/consent/gpp.rs` | Add US section decoding, extract `sale_opt_out` | +| `crates/trusted-server-core/src/consent/mod.rs` | Add GPP US branch in `allows_ec_creation()`, tests | + +No config changes, no new crate dependencies, no `IntegrationRegistry` changes. + +### 5. Testing + +**JS (Vitest):** + +- Mirrors `__gpp` and `__gpp_sid` from `_sp_user_consent_*` localStorage +- No cookies written when no `_sp_user_consent_*` key exists +- Graceful handling of malformed JSON in localStorage + +**Rust — EC gating (`consent/mod.rs`):** + +- EC allowed: US state + GPP `us_sale_opt_out = Some(false)` +- EC blocked: US state + GPP `us_sale_opt_out = Some(true)` +- EC blocked: GPC overrides permissive GPP US +- TCF takes priority over GPP US when both present +- GPP US takes priority over `us_privacy` when both present +- No GPP US section falls through to `us_privacy` +- No signals → fail-closed + +**Rust — GPP decoding (`consent/gpp.rs`):** + +- Extracts `us_sale_opt_out` from GPP string with UsNat section (ID 7) +- `us_sale_opt_out` is `None` when GPP has no US sections + +### 6. Non-goals + +- No `us_privacy` compatibility bridge (skipped per decision) +- No richer US GPP field extraction (sharing, targeted advertising opt-outs) +- No publisher configuration for Sourcepoint property ID (auto-discovery) +- No Sourcepoint CMP API integration (localStorage-only approach) From b049897e109213fcee7967836ee24b5751d74476 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Apr 2026 15:48:46 -0500 Subject: [PATCH 02/22] Add implementation plan for Sourcepoint GPP consent support (#640) --- .../2026-04-15-sourcepoint-gpp-consent.md | 695 ++++++++++++++++++ 1 file changed, 695 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-sourcepoint-gpp-consent.md diff --git a/docs/superpowers/plans/2026-04-15-sourcepoint-gpp-consent.md b/docs/superpowers/plans/2026-04-15-sourcepoint-gpp-consent.md new file mode 100644 index 000000000..8c9de843a --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-sourcepoint-gpp-consent.md @@ -0,0 +1,695 @@ +# Sourcepoint GPP Consent for Edge Cookie Generation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable EC generation for sites using Sourcepoint by mirroring localStorage consent into cookies (client) and recognizing GPP US `sale_opt_out` as a consent signal (server). + +**Architecture:** New JS-only `sourcepoint` integration auto-discovers `_sp_user_consent_*` in localStorage and writes `__gpp` / `__gpp_sid` cookies. Server-side, `GppConsent` gains a `us_sale_opt_out: Option` field extracted from any GPP US section (IDs 7–23). `allows_ec_creation()` checks this field between the existing TCF and `us_privacy` branches. + +**Tech Stack:** TypeScript (Vitest, jsdom), Rust (iab_gpp crate for GPP section decoding) + +**Spec:** `docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `crates/trusted-server-core/src/consent/types.rs` | Modify | Add `us_sale_opt_out: Option` to `GppConsent` | +| `crates/trusted-server-core/src/consent/gpp.rs` | Modify | Decode US sections, extract `sale_opt_out` | +| `crates/trusted-server-core/src/consent/mod.rs` | Modify | Add GPP US branch in `allows_ec_creation()`, tests | +| `crates/js/lib/src/integrations/sourcepoint/index.ts` | Create | localStorage auto-discovery, cookie mirroring | +| `crates/js/lib/test/integrations/sourcepoint/index.test.ts` | Create | Vitest tests for cookie mirroring | + +--- + +## Task 1: Add `us_sale_opt_out` field to `GppConsent` + +**Files:** +- Modify: `crates/trusted-server-core/src/consent/types.rs:297-305` + +- [ ] **Step 1: Add the field** + +In `crates/trusted-server-core/src/consent/types.rs`, add `us_sale_opt_out` to `GppConsent`: + +```rust +/// Decoded GPP (Global Privacy Platform) consent data. +/// +/// Wraps the `iab_gpp` crate's decoded output with our domain types. +#[derive(Debug, Clone)] +pub struct GppConsent { + /// GPP header version. + pub version: u8, + /// Active section IDs present in the GPP string. + pub section_ids: Vec, + /// Decoded EU TCF v2.2 section (if present in GPP, section ID 2). + pub eu_tcf: Option, + /// Whether the user opted out of sale of personal information via a US GPP + /// section (IDs 7–23). + /// + /// - `Some(true)` — a US section is present and `sale_opt_out == OptedOut` + /// - `Some(false)` — a US section is present and user did not opt out + /// - `None` — no US section exists in the GPP string + pub us_sale_opt_out: Option, +} +``` + +- [ ] **Step 2: Fix compilation — update all `GppConsent` construction sites** + +There are existing places that construct `GppConsent`. Each needs the new field. Search for them: + +In `crates/trusted-server-core/src/consent/gpp.rs` (~line 74), update `decode_gpp_string`: + +```rust + Ok(GppConsent { + version: 1, + section_ids, + eu_tcf, + us_sale_opt_out: None, // placeholder — Task 2 fills this in + }) +``` + +In `crates/trusted-server-core/src/consent/mod.rs`, find every test that constructs `GppConsent` (search for `GppConsent {`). Add `us_sale_opt_out: None` to each. There are instances around lines 720, 883, and 965: + +```rust + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(...), + us_sale_opt_out: None, + }), +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cargo check --workspace` +Expected: compiles with no errors. + +- [ ] **Step 4: Run tests to confirm nothing broke** + +Run: `cargo test --workspace` +Expected: all existing tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/consent/types.rs \ + crates/trusted-server-core/src/consent/gpp.rs \ + crates/trusted-server-core/src/consent/mod.rs +git commit -m "Add us_sale_opt_out field to GppConsent" +``` + +--- + +## Task 2: Decode US sale opt-out from GPP sections + +**Files:** +- Modify: `crates/trusted-server-core/src/consent/gpp.rs` + +- [ ] **Step 1: Write the failing test for US sale opt-out extraction** + +Add to the `#[cfg(test)] mod tests` block in `crates/trusted-server-core/src/consent/gpp.rs`: + +```rust + // A GPP string with UsNat section (section ID 7). + // Header "DBABLA" encodes: version=1, section IDs=[7] (UsNat). + // The section string encodes a UsNat v1 core with sale_opt_out=DidNotOptOut (2). + #[test] + fn decodes_us_sale_opt_out_not_opted_out() { + // Build a real GPP string with UsNat section using iab_gpp parsing. + // "DBABLA~BVQqAAAAAgA.QA" is the example from the issue (Sourcepoint payload). + let result = decode_gpp_string("DBABLA~BVQqAAAAAgA.QA"); + match &result { + Ok(gpp) => { + assert_eq!( + gpp.us_sale_opt_out, + Some(false), + "should extract sale_opt_out=false from UsNat section" + ); + } + Err(e) => { + // If the specific GPP string doesn't parse, test with section ID presence. + // The important thing is that the decode_us_sale_opt_out function is wired up. + panic!("GPP decode failed: {e}"); + } + } + } + + #[test] + fn no_us_section_returns_none() { + // GPP_TCF_AND_USP has section IDs [2, 6] — no US sections (7–23). + let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode GPP"); + assert_eq!( + result.us_sale_opt_out, None, + "should return None when no US section (7-23) is present" + ); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p trusted-server-core -- consent::gpp::tests::decodes_us_sale_opt_out` +Expected: FAIL — `us_sale_opt_out` is hardcoded to `None`. + +- [ ] **Step 3: Implement `decode_us_sale_opt_out`** + +In `crates/trusted-server-core/src/consent/gpp.rs`, add after `decode_tcf_from_gpp`: + +```rust +/// GPP section IDs that represent US state/national privacy sections. +/// +/// Range 7–23 per the GPP v1 specification: +/// 7=UsNat, 8=UsCa, 9=UsVa, 10=UsCo, 11=UsUt, 12=UsCt, 13=UsFl, +/// 14=UsMt, 15=UsOr, 16=UsTx, 17=UsDe, 18=UsIa, 19=UsNe, 20=UsNh, +/// 21=UsNj, 22=UsTn, 23=UsMn. +const US_SECTION_ID_RANGE: std::ops::RangeInclusive = 7..=23; + +/// Extracts the `sale_opt_out` signal from the first US section in a parsed +/// GPP string. +/// +/// Iterates through section IDs looking for any in the US range (7–23). +/// For the first match, decodes the section and extracts `sale_opt_out`. +/// +/// Returns `Some(true)` if the user opted out of sale, `Some(false)` if they +/// did not, or `None` if no US section is present. +fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { + use iab_gpp::sections::us_common::OptOut; + use iab_gpp::sections::Section; + + let us_section_id = parsed + .section_ids() + .find(|id| US_SECTION_ID_RANGE.contains(&(**id as u16)))?; + + match parsed.decode_section(*us_section_id) { + Ok(section) => { + let sale_opt_out = match §ion { + Section::UsNat(s) => match &s.core { + iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, + iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out, + }, + Section::UsCa(s) => &s.core.sale_opt_out, + Section::UsVa(s) => &s.core.sale_opt_out, + Section::UsCo(s) => &s.core.sale_opt_out, + Section::UsUt(s) => &s.core.sale_opt_out, + Section::UsCt(s) => &s.core.sale_opt_out, + Section::UsFl(s) => &s.core.sale_opt_out, + Section::UsMt(s) => &s.core.sale_opt_out, + Section::UsOr(s) => &s.core.sale_opt_out, + Section::UsTx(s) => &s.core.sale_opt_out, + Section::UsDe(s) => &s.core.sale_opt_out, + Section::UsIa(s) => &s.core.sale_opt_out, + Section::UsNe(s) => &s.core.sale_opt_out, + Section::UsNh(s) => &s.core.sale_opt_out, + Section::UsNj(s) => &s.core.sale_opt_out, + Section::UsTn(s) => &s.core.sale_opt_out, + Section::UsMn(s) => &s.core.sale_opt_out, + // Non-US sections — should not reach here given the ID filter. + _ => return None, + }; + Some(*sale_opt_out == OptOut::OptedOut) + } + Err(e) => { + log::warn!("Failed to decode US GPP section {us_section_id}: {e}"); + None + } + } +} +``` + +- [ ] **Step 4: Wire it into `decode_gpp_string`** + +In the same file, replace the placeholder in `decode_gpp_string`: + +```rust + let us_sale_opt_out = decode_us_sale_opt_out(&parsed); + + Ok(GppConsent { + version: 1, + section_ids, + eu_tcf, + us_sale_opt_out, + }) +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test --workspace -p trusted-server-core -- consent::gpp::tests` +Expected: all GPP tests pass, including the two new ones. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/consent/gpp.rs +git commit -m "Decode US sale opt-out from GPP sections" +``` + +--- + +## Task 3: Add GPP US branch to `allows_ec_creation()` + +**Files:** +- Modify: `crates/trusted-server-core/src/consent/mod.rs` + +- [ ] **Step 1: Write failing tests** + +Add to the `#[cfg(test)] mod tests` block in `crates/trusted-server-core/src/consent/mod.rs`: + +```rust + #[test] + fn ec_allowed_us_state_gpp_no_sale_opt_out() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "US state + GPP US sale_opt_out=false should allow EC" + ); + } + + #[test] + fn ec_blocked_us_state_gpp_sale_opted_out() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + !allows_ec_creation(&ctx), + "US state + GPP US sale_opt_out=true should block EC" + ); + } + + #[test] + fn ec_blocked_us_state_gpc_overrides_gpp_us() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpc: true, + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + ..ConsentContext::default() + }; + assert!( + !allows_ec_creation(&ctx), + "GPC should block EC even when GPP US says no opt-out" + ); + } + + #[test] + fn ec_us_state_tcf_takes_priority_over_gpp_us() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + tcf: Some(make_tcf_with_storage(true)), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "TCF consent should take priority over GPP US opt-out" + ); + } + + #[test] + fn ec_us_state_gpp_us_takes_priority_over_us_privacy() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(false), + }), + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "GPP US should take priority over us_privacy opt-out" + ); + } + + #[test] + fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { + let ctx = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: None, + us_sale_opt_out: None, + }), + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::No, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + assert!( + allows_ec_creation(&ctx), + "GPP without US section should fall through to us_privacy" + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --workspace -p trusted-server-core -- consent::tests::ec_allowed_us_state_gpp` +Expected: FAIL — the GPP US branch doesn't exist yet, so `ec_allowed_us_state_gpp_no_sale_opt_out` fails (falls through to fail-closed). + +- [ ] **Step 3: Add the GPP US branch to `allows_ec_creation()`** + +In `crates/trusted-server-core/src/consent/mod.rs`, update `allows_ec_creation()`. The `UsState` arm currently reads: + +```rust + jurisdiction::Jurisdiction::UsState(_) => { + if ctx.gpc { + return false; + } + if let Some(tcf) = effective_tcf(ctx) { + return tcf.has_storage_consent(); + } + if let Some(usp) = &ctx.us_privacy { + return usp.opt_out_sale != PrivacyFlag::Yes; + } + false + } +``` + +Insert the GPP US check between TCF and us_privacy: + +```rust + jurisdiction::Jurisdiction::UsState(_) => { + if ctx.gpc { + return false; + } + if let Some(tcf) = effective_tcf(ctx) { + return tcf.has_storage_consent(); + } + // Check GPP US section for sale opt-out. + if let Some(gpp) = &ctx.gpp { + if let Some(opted_out) = gpp.us_sale_opt_out { + return !opted_out; + } + } + if let Some(usp) = &ctx.us_privacy { + return usp.opt_out_sale != PrivacyFlag::Yes; + } + false + } +``` + +- [ ] **Step 4: Run all tests** + +Run: `cargo test --workspace` +Expected: all tests pass, including the six new EC gating tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/consent/mod.rs +git commit -m "Recognize GPP US sale opt-out in EC consent gating" +``` + +--- + +## Task 4: Create Sourcepoint JS integration + +**Files:** +- Create: `crates/js/lib/src/integrations/sourcepoint/index.ts` + +- [ ] **Step 1: Write the test file first** + +Create `crates/js/lib/test/integrations/sourcepoint/index.test.ts`: + +```typescript +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; + +describe('integrations/sourcepoint', () => { + beforeEach(() => { + // Clear cookies and localStorage before each test. + document.cookie.split(';').forEach((c) => { + const name = c.split('=')[0].trim(); + if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + }); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('mirrors __gpp and __gpp_sid from _sp_user_consent_* localStorage', () => { + const payload = { + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [7], + }, + }; + localStorage.setItem('_sp_user_consent_36026', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(document.cookie).toContain('__gpp=DBABLA~BVQqAAAAAgA.QA'); + expect(document.cookie).toContain('__gpp_sid=7'); + }); + + it('handles multiple applicable sections', () => { + const payload = { + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [7, 8], + }, + }; + localStorage.setItem('_sp_user_consent_99999', JSON.stringify(payload)); + + mirrorSourcepointConsent(); + + expect(document.cookie).toContain('__gpp_sid=7,8'); + }); + + it('returns false when no _sp_user_consent_* key exists', () => { + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + expect(document.cookie).not.toContain('__gpp_sid='); + }); + + it('returns false for malformed JSON in localStorage', () => { + localStorage.setItem('_sp_user_consent_12345', 'not-json!!!'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('returns false when gppData is missing from payload', () => { + localStorage.setItem('_sp_user_consent_12345', JSON.stringify({ otherField: true })); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('returns false when gppString is empty', () => { + const payload = { + gppData: { + gppString: '', + applicableSections: [7], + }, + }; + localStorage.setItem('_sp_user_consent_12345', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd crates/js/lib && npx vitest run test/integrations/sourcepoint/index.test.ts` +Expected: FAIL — module `../../../src/integrations/sourcepoint` does not exist. + +- [ ] **Step 3: Implement the integration** + +Create `crates/js/lib/src/integrations/sourcepoint/index.ts`: + +```typescript +import { log } from '../../core/log'; + +const SP_CONSENT_PREFIX = '_sp_user_consent_'; + +interface SourcepointGppData { + gppString: string; + applicableSections: number[]; +} + +interface SourcepointConsentPayload { + gppData?: SourcepointGppData; +} + +function findSourcepointConsent(): SourcepointConsentPayload | null { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; + + const raw = localStorage.getItem(key); + if (!raw) continue; + + try { + return JSON.parse(raw) as SourcepointConsentPayload; + } catch { + log.debug('sourcepoint: failed to parse localStorage value', { key }); + return null; + } + } + return null; +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax`; +} + +/// Reads Sourcepoint consent from localStorage and mirrors it into +/// `__gpp` and `__gpp_sid` cookies for Trusted Server to read. +/// +/// Returns `true` if cookies were written, `false` otherwise. +export function mirrorSourcepointConsent(): boolean { + if (typeof localStorage === 'undefined' || typeof document === 'undefined') { + return false; + } + + const payload = findSourcepointConsent(); + if (!payload?.gppData) { + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + + const { gppString, applicableSections } = payload.gppData; + if (!gppString) { + log.debug('sourcepoint: gppString is empty'); + return false; + } + + writeCookie('__gpp', gppString); + + if (Array.isArray(applicableSections) && applicableSections.length > 0) { + writeCookie('__gpp_sid', applicableSections.join(',')); + } + + log.info('sourcepoint: mirrored GPP consent to cookies', { + gppLength: gppString.length, + sections: applicableSections, + }); + + return true; +} + +if (typeof window !== 'undefined') { + mirrorSourcepointConsent(); +} + +export default mirrorSourcepointConsent; +``` + +- [ ] **Step 4: Run tests** + +Run: `cd crates/js/lib && npx vitest run test/integrations/sourcepoint/index.test.ts` +Expected: all 6 tests pass. + +- [ ] **Step 5: Run the full JS test suite** + +Run: `cd crates/js/lib && npx vitest run` +Expected: all tests pass (existing + new). + +- [ ] **Step 6: Format** + +Run: `cd crates/js/lib && npm run format` +Expected: no formatting issues. + +- [ ] **Step 7: Commit** + +```bash +git add crates/js/lib/src/integrations/sourcepoint/index.ts \ + crates/js/lib/test/integrations/sourcepoint/index.test.ts +git commit -m "Add Sourcepoint JS integration for GPP consent cookie mirroring" +``` + +--- + +## Task 5: Final verification + +**Files:** None (verification only) + +- [ ] **Step 1: Build the JS bundles** + +Run: `cd crates/js/lib && node build-all.mjs` +Expected: builds successfully, `dist/tsjs-sourcepoint.js` appears in the output. + +- [ ] **Step 2: Full Rust build** + +Run: `cargo build --workspace` +Expected: compiles with no errors. + +- [ ] **Step 3: Full Rust test suite** + +Run: `cargo test --workspace` +Expected: all tests pass. + +- [ ] **Step 4: Clippy** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: no warnings. + +- [ ] **Step 5: Rust format check** + +Run: `cargo fmt --all -- --check` +Expected: no formatting issues. + +- [ ] **Step 6: Full JS test suite** + +Run: `cd crates/js/lib && npx vitest run` +Expected: all tests pass. + +- [ ] **Step 7: JS format check** + +Run: `cd crates/js/lib && npm run format` +Expected: no formatting issues. From 869e8cde8974450ca581c45d376ccd053679615f Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Apr 2026 15:52:34 -0500 Subject: [PATCH 03/22] Add us_sale_opt_out field to GppConsent --- crates/trusted-server-core/src/consent/gpp.rs | 1 + crates/trusted-server-core/src/consent/mod.rs | 3 +++ crates/trusted-server-core/src/consent/types.rs | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index 9d0e5c81f..76b69e087 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -76,6 +76,7 @@ pub fn decode_gpp_string(gpp_string: &str) -> Result, /// 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, } // --------------------------------------------------------------------------- From cb9e2b56318d1e64c6a7e2c49d29c1851fcd9976 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Apr 2026 15:54:47 -0500 Subject: [PATCH 04/22] Decode US sale opt-out from GPP sections --- crates/trusted-server-core/src/consent/gpp.rs | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index 76b69e087..d6e8902ed 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -72,11 +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 from the first US section in a parsed +/// GPP string. +/// +/// Iterates through section IDs looking for any in the US range (7–23). +/// For the first match, decodes the section and extracts `sale_opt_out`. +/// +/// Returns `Some(true)` if the user opted out of sale, `Some(false)` if they +/// did not, or `None` if no US section is present. +fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { + use iab_gpp::sections::us_common::OptOut; + use iab_gpp::sections::Section; + + let us_section_id = parsed + .section_ids() + .find(|id| US_SECTION_ID_RANGE.contains(&(**id as u16)))?; + + match parsed.decode_section(*us_section_id) { + Ok(section) => { + let sale_opt_out = match §ion { + Section::UsNat(s) => match &s.core { + iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, + iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out, + _ => 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, + // Non-US sections — should not reach here given the ID filter. + _ => return None, + }; + Some(*sale_opt_out == OptOut::OptedOut) + } + Err(e) => { + log::warn!("Failed to decode US GPP section {us_section_id}: {e}"); + None + } + } +} + /// 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"`. @@ -240,4 +302,30 @@ 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}"); + } + } + } + + #[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" + ); + } } From d226377c77f3964a00bea65be1aa5020bb7ef354 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Apr 2026 15:57:34 -0500 Subject: [PATCH 05/22] Recognize GPP US sale opt-out in EC consent gating --- crates/trusted-server-core/src/consent/mod.rs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index b75f8b2f1..4a7b5ce22 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -492,6 +492,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; @@ -1104,4 +1110,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" + ); + } } From 5ba2bc50b2d31957cb3fcbc7d43421dbbb08e67a Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Apr 2026 15:58:57 -0500 Subject: [PATCH 06/22] Add Sourcepoint JS integration for GPP consent cookie mirroring --- .../lib/src/integrations/sourcepoint/index.ts | 77 ++++++++++++++++ .../integrations/sourcepoint/index.test.ts | 91 +++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 crates/js/lib/src/integrations/sourcepoint/index.ts create mode 100644 crates/js/lib/test/integrations/sourcepoint/index.test.ts 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 000000000..850659dcf --- /dev/null +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -0,0 +1,77 @@ +import { log } from '../../core/log'; + +const SP_CONSENT_PREFIX = '_sp_user_consent_'; + +interface SourcepointGppData { + gppString: string; + applicableSections: number[]; +} + +interface SourcepointConsentPayload { + gppData?: SourcepointGppData; +} + +function findSourcepointConsent(): SourcepointConsentPayload | null { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; + + const raw = localStorage.getItem(key); + if (!raw) continue; + + try { + return JSON.parse(raw) as SourcepointConsentPayload; + } catch { + log.debug('sourcepoint: failed to parse localStorage value', { key }); + return null; + } + } + return null; +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; path=/; SameSite=Lax`; +} + +/** + * Reads Sourcepoint consent from localStorage and mirrors it into + * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. + * + * Returns `true` if cookies were written, `false` otherwise. + */ +export function mirrorSourcepointConsent(): boolean { + if (typeof localStorage === 'undefined' || typeof document === 'undefined') { + return false; + } + + const payload = findSourcepointConsent(); + if (!payload?.gppData) { + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + + const { gppString, applicableSections } = payload.gppData; + if (!gppString) { + log.debug('sourcepoint: gppString is empty'); + return false; + } + + writeCookie('__gpp', gppString); + + if (Array.isArray(applicableSections) && applicableSections.length > 0) { + writeCookie('__gpp_sid', applicableSections.join(',')); + } + + log.info('sourcepoint: mirrored GPP consent to cookies', { + gppLength: gppString.length, + sections: applicableSections, + }); + + return true; +} + +if (typeof window !== 'undefined') { + mirrorSourcepointConsent(); +} + +export default mirrorSourcepointConsent; 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 000000000..4eaf763f6 --- /dev/null +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; + +describe('integrations/sourcepoint', () => { + beforeEach(() => { + // Clear cookies and localStorage before each test. + document.cookie.split(';').forEach((c) => { + const name = c.split('=')[0].trim(); + if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + }); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('mirrors __gpp and __gpp_sid from _sp_user_consent_* localStorage', () => { + const payload = { + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [7], + }, + }; + localStorage.setItem('_sp_user_consent_36026', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(document.cookie).toContain('__gpp=DBABLA~BVQqAAAAAgA.QA'); + expect(document.cookie).toContain('__gpp_sid=7'); + }); + + it('handles multiple applicable sections', () => { + const payload = { + gppData: { + gppString: 'DBABLA~BVQqAAAAAgA.QA', + applicableSections: [7, 8], + }, + }; + localStorage.setItem('_sp_user_consent_99999', JSON.stringify(payload)); + + mirrorSourcepointConsent(); + + expect(document.cookie).toContain('__gpp_sid=7,8'); + }); + + it('returns false when no _sp_user_consent_* key exists', () => { + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + expect(document.cookie).not.toContain('__gpp_sid='); + }); + + it('returns false for malformed JSON in localStorage', () => { + localStorage.setItem('_sp_user_consent_12345', 'not-json!!!'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('returns false when gppData is missing from payload', () => { + localStorage.setItem('_sp_user_consent_12345', JSON.stringify({ otherField: true })); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); + + it('returns false when gppString is empty', () => { + const payload = { + gppData: { + gppString: '', + applicableSections: [7], + }, + }; + localStorage.setItem('_sp_user_consent_12345', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(document.cookie).not.toContain('__gpp='); + }); +}); From 945f39a2085b405087ec45b4c1dc204cb12441a8 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 11:55:16 -0500 Subject: [PATCH 07/22] Add design spec for Prebid User ID Module support --- ...2026-04-16-prebid-user-id-module-design.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md diff --git a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md new file mode 100644 index 000000000..bc0f3f669 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md @@ -0,0 +1,198 @@ +# Prebid User ID Module support + +**Date:** 2026-04-16 +**Status:** Design +**Scope:** JS bundle (`crates/js/lib/src/integrations/prebid/index.ts`) + +## Problem + +The Trusted Server Prebid integration strips each publisher's origin `prebid.js` +and replaces it with a server-bundled build. That bundle imports the consent +management modules but does **not** import Prebid's User ID core module or any +ID submodules. As a result `pbjs.getUserIdsAsEids` is `undefined` at runtime, +the `syncPrebidEidsCookie()` helper early-returns, and the `ts-eids` cookie is +never written — even when the publisher's origin-side code has a fully +configured `userSync.userIds` list. + +Downstream, `crates/trusted-server-core/src/ec/prebid_eids.rs` never receives a +cookie to ingest, so matched partner UIDs never land in the KV identity graph. + +## Goal + +Bundle Prebid's User ID core module and a broad, widely-deployed set of ID +submodules so publishers' existing `pbjs.setConfig({ userSync: { userIds: ... } })` +calls activate real ID resolution. After first auction completes, `ts-eids` +cookie is written and the backend ingestion path (already implemented) takes +over. + +## Non-goals + +- No Rust changes. No new `trusted-server.toml` fields. +- No runtime config injection from the server (`window.__tsjs_prebid.userIds`) + — deferred to a follow-up. +- No build-time env-var toggle for the bundled set (e.g. `TSJS_PREBID_USER_IDS` + mirroring `TSJS_PREBID_ADAPTERS`) — deferred to a follow-up. +- No automatic alignment between bundled ID submodules and configured + `[[ec.partners]]` — operators must keep those in sync themselves. + +## Design + +### Bundled modules + +Exactly one file changes: `crates/js/lib/src/integrations/prebid/index.ts`. +Add static imports near the existing `consentManagement*.js` imports. + +**Core (required):** + +- `prebid.js/modules/userId.js` + +**Zero-config / auto-populating submodules** (resolve without publisher params): + +- `prebid.js/modules/sharedIdSystem.js` +- `prebid.js/modules/criteoIdSystem.js` +- `prebid.js/modules/33acrossIdSystem.js` +- `prebid.js/modules/pubProvidedIdSystem.js` +- `prebid.js/modules/quantcastIdSystem.js` + +**Param-based submodules** (inert until the publisher's `setConfig` supplies +the relevant params): + +- `prebid.js/modules/id5IdSystem.js` +- `prebid.js/modules/identityLinkIdSystem.js` +- `prebid.js/modules/liveIntentIdSystem.js` +- `prebid.js/modules/uid2IdSystem.js` +- `prebid.js/modules/euidIdSystem.js` +- `prebid.js/modules/intentIqIdSystem.js` +- `prebid.js/modules/lotamePanoramaIdSystem.js` +- `prebid.js/modules/connectIdSystem.js` +- `prebid.js/modules/merkleIdSystem.js` + +**Legacy / compatibility:** + +- `prebid.js/modules/pubCommonIdSystem.js` — deprecated in favor of SharedID + but still present in some publisher configs. + +Total: 1 core + 15 submodules = 16 new imports. + +No changes to `installPrebidNpm`, no changes to the `bidsBackHandler` shim, no +changes to `syncPrebidEidsCookie`. The existing cookie-writing path is already +correct — it was only silent because `pbjs.getUserIdsAsEids` did not exist. + +### Runtime flow + +No new runtime logic. The sequence below is what will light up once the +submodules are present: + +1. Rust `IntegrationHeadInjector` emits the `window.pbjs` / `window.pbjs.que` + / `window.__tsjs_prebid` bootstrap before any publisher-origin script runs. +2. Publisher origin code queues its existing config: + `pbjs.que.push(() => pbjs.setConfig({ userSync: { userIds: [...] } }))`. +3. Our bundle loads. `installPrebidNpm()` registers the `trustedServer` + adapter, shims `requestBids` (already appends a chained `bidsBackHandler` + calling `syncPrebidEidsCookie`), then calls `pbjs.processQueue()` — the + publisher's queued `setConfig` runs at this point and activates the + configured submodules (each self-registered at import time). +4. User ID Module resolves IDs per its own rules (TCF/GPP/USP-gated, async). +5. First `requestBids` fires. Auction completes. Chained `bidsBackHandler` + calls `syncPrebidEidsCookie()`. +6. `syncPrebidEidsCookie` calls `pbjs.getUserIdsAsEids()` (now a real + function), flattens `[{source, id, atype}]`, base64-encodes JSON, writes + `document.cookie = "ts-eids=..."`. +7. Subsequent `/auction` requests carry `Cookie: ts-eids=...`. +8. Backend (`crates/trusted-server-core/src/ec/prebid_eids.rs`) parses the + cookie, matches `source` against `[[ec.partners]]`, syncs partner UIDs to + KV. + +The first `/auction` request after a cold page load still will not carry +`ts-eids`, because the cookie is written in the post-auction handler. This +matches preexisting behavior. + +### Error handling + +All failure modes are already covered by existing code. No new error paths. + +- **Publisher has no `userSync.userIds` configured** → + `pbjs.getUserIdsAsEids()` returns `[]` → early-return at `index.ts:380-382`. + No cookie written. Silent. Correct. +- **Submodule fails to resolve** (no consent, no third-party ID, network + error) → handled inside Prebid; `getUserIdsAsEids()` returns only the + resolved subset. Cookie reflects what resolved. +- **Cookie payload exceeds 3072 bytes** → existing trim-and-retry loop at + `index.ts:404-411` drops entries from the tail until it fits. If a single + entry alone exceeds the cap, no cookie is written. +- **Unexpected exception in sync path** → caught by the existing `try/catch` + at `index.ts:417-419`, logged via `log.warn`, does not break the auction. +- **Module import failure at build time** → esbuild fails the build. This + catches missing or renamed Prebid modules before they ship. + +### Known caveats + +- **Backend pairing** — an EID whose `source` has no matching `[[ec.partners]]` + entry is dropped at the backend (with a debug log). Bundling + `id5IdSystem.js` is inert for EC identity-graph purposes unless the + operator also adds an `[[ec.partners]]` entry with + `source_domain = "id5-sync.com"`. Operators must keep the two lists in + sync. Not a code change here; documented as an operator concern. +- **Bundle size** — adding 15 modules increases the shipped `tsjs-prebid.js` + by an estimated ~100-150kb gzipped. Not gated on a build-time toggle in + this change. + +## Testing + +### Automated (Vitest) + +Add tests under `crates/js/lib/src/integrations/prebid/`: + +- **Import smoke test** — import `./index.ts` and assert + `typeof pbjs.getUserIdsAsEids === 'function'`. Guards against the exact + regression that motivated this work. +- **`syncPrebidEidsCookie` unit tests** (new or expanded) — mock + `pbjs.getUserIdsAsEids` to return a fixed `[{source, uids: [{id, atype}]}]` + array and assert the cookie is written with base64-encoded + `[{source, id, atype}]`. Cover: + - empty array → no cookie written + - normal payload → cookie written with expected value + - oversize payload → trimmed to fit; partial entries persisted + - single oversize entry → no cookie written + +### Manual (after deploy to a dev publisher) + +- DevTools console: `typeof pbjs.getUserIdsAsEids === 'function'` returns + `true`. +- `pbjs.getUserIdsAsEids()` returns a non-empty array for a publisher with + configured `userIds`. +- After the first auction: `document.cookie` contains `ts-eids=...`. Decoded + payload (base64 → JSON) matches the raw EIDs. +- Network tab: second `/auction` request carries `Cookie: ts-eids=...`. + +### Explicitly out of scope + +- Each individual ID submodule's resolution behavior — that is Prebid's + responsibility and covered by Prebid's own test suite. +- Backend ingestion of `ts-eids` — already covered by `prebid_eids.rs` + tests; no new backend code. +- Bundle-size regression gating — noted as a caveat, not enforced. + +## Rollout + +This is a bundle change only. No migration, no feature flag, no staged +rollout beyond normal deploy. + +On first deploy, publishers with active origin-side `userSync.userIds` +configuration will begin emitting `ts-eids` cookies after their first +auction. Publishers without `userSync.userIds` configured see no change. + +## Follow-ups + +1. **Build-time configurability** — introduce `_user_ids.generated.ts` + driven by a `TSJS_PREBID_USER_IDS` env var, mirroring the existing + `TSJS_PREBID_ADAPTERS` / `_adapters.generated.ts` pattern. Allows + operators to slim the bundle per deployment. +2. **Server-injected `userSync.userIds`** — extend `trusted-server.toml` + with a `[[integrations.prebid.user_ids]]` array. Rust serializes into + `window.__tsjs_prebid.userIds`. JS applies via `pbjs.setConfig` before + `processQueue()`. Supports publishers who do not run their own Prebid + config on origin. +3. **Partner alignment tooling** — a startup-time check that warns when a + bundled ID submodule has no matching `[[ec.partners]]` entry, or vice + versa. From f71e55fc60c22c2c8aaba8e53c2106f0a9abe02d Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 12:00:21 -0500 Subject: [PATCH 08/22] Add implementation plan for Prebid User ID Module support --- .../plans/2026-04-16-prebid-user-id-module.md | 575 ++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-16-prebid-user-id-module.md diff --git a/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md b/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md new file mode 100644 index 000000000..534422fa2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md @@ -0,0 +1,575 @@ +# Prebid User ID Module — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bundle Prebid's User ID core module and a broad set of ID submodules so publisher-side `pbjs.setConfig({ userSync: { userIds: [...] } })` calls activate real ID resolution and the existing `syncPrebidEidsCookie()` helper begins writing `ts-eids`. + +**Architecture:** JS-only change. Add 16 static imports (1 core + 15 submodules) to `crates/js/lib/src/integrations/prebid/index.ts`. No Rust changes, no TOML changes, no new runtime logic. Existing `bidsBackHandler` shim and cookie-sync path already handle the rest; they were silent only because `pbjs.getUserIdsAsEids` did not exist. + +**Tech Stack:** TypeScript, Vitest, esbuild (via `build-all.mjs`), Prebid.js 9.x (via npm) + +**Spec:** `docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `crates/js/lib/src/integrations/prebid/index.ts` | Modify | Add 16 User ID Module imports alongside existing `consentManagement*` imports | +| `crates/js/lib/test/integrations/prebid/index.test.ts` | Modify | Add `vi.mock` stubs for the 16 new modules, add `mockPbjs.getUserIdsAsEids`, add new tests for cookie-sync behavior and import regression guard | + +No other files change. `build.rs` picks up the rebuilt `dist/tsjs-prebid.js` automatically via `include_str!`. + +--- + +## Task 1: Document existing `syncPrebidEidsCookie` behavior with tests + +The sync helper already exists but has no test coverage. Before changing anything, lock in its current contract so we can refactor or extend later without regressions. These tests exercise the `bidsBackHandler` shim path end-to-end using the existing mocks. + +**Files:** +- Modify: `crates/js/lib/test/integrations/prebid/index.test.ts` + +- [ ] **Step 1: Add `getUserIdsAsEids` to the hoisted pbjs mock** + +In `crates/js/lib/test/integrations/prebid/index.test.ts` inside the existing `vi.hoisted(() => { ... })` block, add a new mock function and include it on `mockPbjs`. Replace the current block with: + +```ts +const { + mockSetConfig, + mockProcessQueue, + mockRequestBids, + mockRegisterBidAdapter, + mockGetUserIdsAsEids, + mockPbjs, + mockGetBidAdapter, + mockAdapterManager, +} = vi.hoisted(() => { + const mockSetConfig = vi.fn(); + const mockProcessQueue = vi.fn(); + 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 = { + getBidAdapter: mockGetBidAdapter, + }; + return { + mockSetConfig, + mockProcessQueue, + mockRequestBids, + mockRegisterBidAdapter, + mockGetUserIdsAsEids, + mockPbjs, + mockGetBidAdapter, + mockAdapterManager, + }; +}); +``` + +- [ ] **Step 2: Write the failing test — empty EID array writes no cookie** + +Append this new `describe` block at the end of `crates/js/lib/test/integrations/prebid/index.test.ts`: + +```ts +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(); + }); +}); +``` + +- [ ] **Step 3: Run the test — expect PASS (documents existing behavior)** + +Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "writes no cookie when getUserIdsAsEids returns empty array"` + +Expected: PASS. The shim already calls `syncPrebidEidsCookie` which early-returns on empty input. + +- [ ] **Step 4: Add test — writes base64 cookie for a normal payload** + +Append inside the same `describe` block: + +```ts + 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 }, + ]); + }); +``` + +- [ ] **Step 5: Run and confirm PASS** + +Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "writes ts-eids cookie with base64"` + +Expected: PASS. + +- [ ] **Step 6: Add test — defaults atype to 3 when missing** + +Append: + +```ts + 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 }]); + }); +``` + +- [ ] **Step 7: Add test — skips entries without an id or source** + +Append: + +```ts + 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 }]); + }); +``` + +- [ ] **Step 8: Add test — takes first uid when multiple are present** + +Append: + +```ts + 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 }]); + }); +``` + +- [ ] **Step 9: Add test — trims tail when payload exceeds 3072 bytes** + +Append: + +```ts + 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'); + }); +``` + +- [ ] **Step 10: Add test — writes no cookie when a single entry alone exceeds the cap** + +Append: + +```ts + 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(); + }); +``` + +- [ ] **Step 11: Add test — does not throw when getUserIdsAsEids is undefined** + +This mirrors the pre-fix production state and guards against regressions in the defensive check at `index.ts:375`. Append: + +```ts + 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; + }); +``` + +- [ ] **Step 12: Add test — calls the original bidsBackHandler when one was supplied** + +Append: + +```ts + 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); + }); +``` + +- [ ] **Step 13: Run the full new block and confirm all pass** + +Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "syncPrebidEidsCookie"` + +Expected: all 9 new tests PASS. If any fail, investigate before proceeding — the rest of the plan assumes this behavior is locked in. + +- [ ] **Step 14: Run the entire prebid test file to confirm no regressions** + +Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts` + +Expected: all tests PASS (new + existing). + +- [ ] **Step 15: Commit** + +```bash +git add crates/js/lib/test/integrations/prebid/index.test.ts +git commit -m "Add Vitest coverage for Prebid ts-eids cookie sync" +``` + +--- + +## Task 2: Add Prebid User ID core and submodule imports + +This is the substantive change. Add `vi.mock` stubs for the new modules first (so tests don't blow up when the imports are added), then add the imports. + +**Files:** +- Modify: `crates/js/lib/test/integrations/prebid/index.test.ts:44-47` +- Modify: `crates/js/lib/src/integrations/prebid/index.ts:16-18` + +- [ ] **Step 1: Add `vi.mock` stubs in the test file for all 16 new modules** + +In `crates/js/lib/test/integrations/prebid/index.test.ts`, locate the existing block (around line 44-47): + +```ts +// Side-effect imports are no-ops in tests +vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); +vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); +vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); +``` + +Replace it with (consent management mocks stay; add 16 new ones): + +```ts +// Side-effect imports are no-ops in tests +vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); +vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); +vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); + +// User ID Module core + submodules — no-op mocks so jsdom does not try to +// execute the real Prebid code paths. +vi.mock('prebid.js/modules/userId.js', () => ({})); +vi.mock('prebid.js/modules/sharedIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/criteoIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/33acrossIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/pubProvidedIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/quantcastIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/id5IdSystem.js', () => ({})); +vi.mock('prebid.js/modules/identityLinkIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/liveIntentIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/uid2IdSystem.js', () => ({})); +vi.mock('prebid.js/modules/euidIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/intentIqIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/lotamePanoramaIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/connectIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/merkleIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/pubCommonIdSystem.js', () => ({})); +``` + +- [ ] **Step 2: Write the failing regression-guard test** + +Append this `describe` block to `crates/js/lib/test/integrations/prebid/index.test.ts`: + +```ts +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('prebid/index.ts User ID Module imports (regression guard)', () => { + const REQUIRED_IMPORTS = [ + 'prebid.js/modules/userId.js', + 'prebid.js/modules/sharedIdSystem.js', + 'prebid.js/modules/criteoIdSystem.js', + 'prebid.js/modules/33acrossIdSystem.js', + 'prebid.js/modules/pubProvidedIdSystem.js', + 'prebid.js/modules/quantcastIdSystem.js', + 'prebid.js/modules/id5IdSystem.js', + 'prebid.js/modules/identityLinkIdSystem.js', + 'prebid.js/modules/liveIntentIdSystem.js', + 'prebid.js/modules/uid2IdSystem.js', + 'prebid.js/modules/euidIdSystem.js', + 'prebid.js/modules/intentIqIdSystem.js', + 'prebid.js/modules/lotamePanoramaIdSystem.js', + 'prebid.js/modules/connectIdSystem.js', + 'prebid.js/modules/merkleIdSystem.js', + 'prebid.js/modules/pubCommonIdSystem.js', + ]; + + // Source-text check: these mocks make the runtime pbjs mock a no-op for the + // User ID Module, so there is no way to assert `typeof getUserIdsAsEids === + // 'function'` at import time from within Vitest. Reading the source file + // directly is the most reliable way to catch accidental removal of an + // import, which is the exact regression that motivated this work. + const SOURCE_PATH = resolve(__dirname, '../../../src/integrations/prebid/index.ts'); + const source = readFileSync(SOURCE_PATH, 'utf8'); + + for (const module of REQUIRED_IMPORTS) { + it(`statically imports ${module}`, () => { + const pattern = new RegExp(`import\\s+['"]${module.replace(/\./g, '\\.')}['"]`); + expect(source).toMatch(pattern); + }); + } +}); +``` + +- [ ] **Step 3: Run the new block — expect 16 failures** + +Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "regression guard"` + +Expected: FAIL — 16 failing assertions, one per expected import. This confirms the regression guard actually reads the source. + +- [ ] **Step 4: Add the 16 imports to `index.ts`** + +In `crates/js/lib/src/integrations/prebid/index.ts`, locate lines 16-18: + +```ts +import 'prebid.js/modules/consentManagementTcf.js'; +import 'prebid.js/modules/consentManagementGpp.js'; +import 'prebid.js/modules/consentManagementUsp.js'; +``` + +Insert the User ID imports immediately after them, before the existing `// Client-side bid adapters` comment block. The resulting section must read: + +```ts +import 'prebid.js/modules/consentManagementTcf.js'; +import 'prebid.js/modules/consentManagementGpp.js'; +import 'prebid.js/modules/consentManagementUsp.js'; + +// Prebid User ID Module — core + submodules. The core module exposes +// `pbjs.getUserIdsAsEids`; submodules self-register at import time and +// activate when the publisher's origin-side `pbjs.setConfig({ userSync: +// { userIds: [...] } })` call runs during `processQueue()`. +import 'prebid.js/modules/userId.js'; + +// Zero-config / auto-populating submodules (resolve without publisher params). +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'; + +// Param-based submodules — inert until publisher setConfig supplies params. +import 'prebid.js/modules/id5IdSystem.js'; +import 'prebid.js/modules/identityLinkIdSystem.js'; +import 'prebid.js/modules/liveIntentIdSystem.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'; + +// Legacy / deprecated but still present in some publisher configs. +import 'prebid.js/modules/pubCommonIdSystem.js'; +``` + +- [ ] **Step 5: Run the regression-guard block — expect PASS** + +Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "regression guard"` + +Expected: all 16 tests PASS. + +- [ ] **Step 6: Run the full prebid test file — expect no regressions** + +Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts` + +Expected: all tests PASS (Task 1 tests + regression guards + all pre-existing tests). + +- [ ] **Step 7: Run the entire JS test suite** + +Run: `cd crates/js/lib && npx vitest run` + +Expected: all PASS. If any unrelated test fails because it imports `./index.ts` transitively and the real Prebid modules are not mocked there, add matching `vi.mock` stubs at the top of that test file (copy the same 16 lines). Common suspects: any test under `crates/js/lib/test/integrations/prebid/` you may have added, or tests that import core modules which in turn import the prebid integration. At the time of writing, no other test file imports the prebid integration. + +- [ ] **Step 8: Build the JS bundles** + +Run: `cd crates/js/lib && node build-all.mjs` + +Expected: build succeeds. `dist/tsjs-prebid.js` gets substantially larger (est. 100-150kb gzipped increase). No esbuild errors about missing modules — if there are, the module path in the new imports is wrong (check `crates/js/lib/node_modules/prebid.js/modules/` for the exact filename — note `33acrossIdSystem.js` really does start with a digit and is correct). + +- [ ] **Step 9: Format the JS** + +Run: `cd crates/js/lib && npm run format` + +Expected: prettier rewrites any formatting drift in the files you touched. No errors. + +- [ ] **Step 10: Verify the Rust build picks up the rebuilt bundle** + +Run: `cargo check --package trusted-server-core` + +Expected: PASS. `build.rs` re-runs because `dist/tsjs-prebid.js` changed; `include_str!` pulls in the new content. + +- [ ] **Step 11: Run full Rust test suite to confirm no downstream breakage** + +Run: `cargo test --workspace` + +Expected: PASS. The Rust side does not inspect bundle contents, only concatenates and hashes them, so tests should be unaffected. + +- [ ] **Step 12: Commit** + +```bash +git add crates/js/lib/src/integrations/prebid/index.ts crates/js/lib/test/integrations/prebid/index.test.ts +git commit -m "Bundle Prebid User ID core and submodules in Prebid integration" +``` + +--- + +## Task 3: Final verification + +- [ ] **Step 1: Full CI-equivalent check** + +Run the same sequence CI runs: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace +cd crates/js/lib && npx vitest run && cd ../../.. +cd crates/js/lib && npm run format && cd ../../.. +``` + +Expected: everything PASS / clean. + +- [ ] **Step 2: Manual verification note** + +Manual browser verification (cannot be automated here; run against a dev publisher environment that has origin-side `pbjs.setConfig({ userSync: { userIds: [...] } })`): + +1. Load a publisher page. In DevTools console: `typeof pbjs.getUserIdsAsEids` should return `'function'`. +2. `pbjs.getUserIdsAsEids()` should return a non-empty array. +3. After the first ad-slot auction completes: `document.cookie.match(/ts-eids=/)` should match. +4. Decode the cookie: `JSON.parse(atob(document.cookie.match(/ts-eids=([^;]+)/)[1]))` should produce a `[{source, id, atype}]` array matching the raw EIDs. +5. Network tab: the second `/auction` request should carry `Cookie: ts-eids=...`. + +These are documented in the spec; they are not blockers for the PR, but they should be run before closing out the work. + +- [ ] **Step 3: No follow-up commits required** + +The work is complete when Tasks 1 and 2 are committed. Do not create a third "chore" commit unless format/clippy asks for one. + +--- + +## What this plan intentionally does NOT do + +- Does **not** add a build-time env-var toggle (`TSJS_PREBID_USER_IDS`) to mirror `TSJS_PREBID_ADAPTERS`. Deferred per spec. +- Does **not** add `window.__tsjs_prebid.userIds` server-side injection. Deferred per spec. +- Does **not** change `[[ec.partners]]` or `crates/trusted-server-core/src/ec/prebid_eids.rs`. Backend already handles received cookies correctly. +- Does **not** add a bundle-size regression gate. Noted as a known cost in the spec. +- Does **not** add tests for individual ID submodule resolution behavior. That is Prebid's own test surface, not ours. From d19e88811483f405caac9871011ae3ac6c69d760 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 12:01:49 -0500 Subject: [PATCH 09/22] Fix ESM path resolution in Prebid User ID plan regression guard --- docs/superpowers/plans/2026-04-16-prebid-user-id-module.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md b/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md index 534422fa2..9a1e33502 100644 --- a/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md +++ b/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md @@ -391,6 +391,7 @@ Append this `describe` block to `crates/js/lib/test/integrations/prebid/index.te ```ts import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; describe('prebid/index.ts User ID Module imports (regression guard)', () => { @@ -418,7 +419,10 @@ describe('prebid/index.ts User ID Module imports (regression guard)', () => { // 'function'` at import time from within Vitest. Reading the source file // directly is the most reliable way to catch accidental removal of an // import, which is the exact regression that motivated this work. - const SOURCE_PATH = resolve(__dirname, '../../../src/integrations/prebid/index.ts'); + // The package is ESM (`"type": "module"`), so `__dirname` is not defined — + // resolve relative to this file via `import.meta.url`. + const THIS_DIR = fileURLToPath(new URL('.', import.meta.url)); + const SOURCE_PATH = resolve(THIS_DIR, '../../../src/integrations/prebid/index.ts'); const source = readFileSync(SOURCE_PATH, 'utf8'); for (const module of REQUIRED_IMPORTS) { From 7660ce8897a05bc860338985ab9abdfabd9651a9 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 12:03:12 -0500 Subject: [PATCH 10/22] Add Vitest coverage for Prebid ts-eids cookie sync --- .../test/integrations/prebid/index.test.ts | 184 +++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index e7cfd3a7a..2af4d6ec9 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -6,9 +6,9 @@ const { mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, + mockGetUserIdsAsEids, mockPbjs, mockGetBidAdapter, - mockGetUserIdsAsEids, mockAdapterManager, } = vi.hoisted(() => { const mockSetConfig = vi.fn(); @@ -16,7 +16,9 @@ const { const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); const mockGetBidAdapter = vi.fn(); - const mockGetUserIdsAsEids = vi.fn(); + const mockGetUserIdsAsEids = vi.fn( + () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> + ); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, @@ -33,9 +35,9 @@ const { mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, + mockGetUserIdsAsEids, mockPbjs, mockGetBidAdapter, - mockGetUserIdsAsEids, mockAdapterManager, }; }); @@ -969,3 +971,179 @@ 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('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('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('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); + }); +}); From fe2d0651c43da6d06837ec60dbd8fa792166559c Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 12:07:59 -0500 Subject: [PATCH 11/22] Bundle Prebid User ID core and submodules in Prebid integration --- .../js/lib/src/integrations/prebid/index.ts | 24 +++++++ .../test/integrations/prebid/index.test.ts | 62 ++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 6b03e820c..99ac3fb82 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -18,6 +18,30 @@ import 'prebid.js/modules/consentManagementGpp.js'; import 'prebid.js/modules/consentManagementUsp.js'; import 'prebid.js/modules/userId.js'; +// Prebid User ID Module — core + submodules. The core module exposes +// `pbjs.getUserIdsAsEids`; submodules self-register at import time and +// activate when the publisher's origin-side `pbjs.setConfig({ userSync: +// { userIds: [...] } })` call runs during `processQueue()`. +import 'prebid.js/modules/userId.js'; + +// Zero-config / auto-populating submodules (resolve without publisher params). +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'; + +// Param-based submodules — inert until publisher setConfig supplies params. +import 'prebid.js/modules/id5IdSystem.js'; +import 'prebid.js/modules/identityLinkIdSystem.js'; +import 'prebid.js/modules/liveIntentIdSystem.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'; + // 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). diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 2af4d6ec9..30156d9b7 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 @@ -53,6 +56,24 @@ vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); vi.mock('prebid.js/modules/userId.js', () => ({})); +// User ID Module core + submodules — no-op mocks so jsdom does not try to +// execute the real Prebid code paths. +vi.mock('prebid.js/modules/userId.js', () => ({})); +vi.mock('prebid.js/modules/sharedIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/criteoIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/33acrossIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/pubProvidedIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/quantcastIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/id5IdSystem.js', () => ({})); +vi.mock('prebid.js/modules/identityLinkIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/liveIntentIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/uid2IdSystem.js', () => ({})); +vi.mock('prebid.js/modules/euidIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/intentIqIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/lotamePanoramaIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/connectIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/merkleIdSystem.js', () => ({})); + // Mock the build-generated adapter imports (no-op in tests) vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); @@ -1039,9 +1060,7 @@ describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { it('defaults atype to 3 when the uid omits it', () => { wireBidsBackHandler(); const pbjs = installPrebidNpm(); - mockGetUserIdsAsEids.mockReturnValue([ - { source: 'example.com', uids: [{ id: 'no-atype' }] }, - ]); + mockGetUserIdsAsEids.mockReturnValue([{ source: 'example.com', uids: [{ id: 'no-atype' }] }]); pbjs.requestBids({ adUnits: [] } as any); @@ -1147,3 +1166,40 @@ describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { expect(originalHandler).toHaveBeenCalledTimes(1); }); }); + +describe('prebid/index.ts User ID Module imports (regression guard)', () => { + const REQUIRED_IMPORTS = [ + 'prebid.js/modules/userId.js', + 'prebid.js/modules/sharedIdSystem.js', + 'prebid.js/modules/criteoIdSystem.js', + 'prebid.js/modules/33acrossIdSystem.js', + 'prebid.js/modules/pubProvidedIdSystem.js', + 'prebid.js/modules/quantcastIdSystem.js', + 'prebid.js/modules/id5IdSystem.js', + 'prebid.js/modules/identityLinkIdSystem.js', + 'prebid.js/modules/liveIntentIdSystem.js', + 'prebid.js/modules/uid2IdSystem.js', + 'prebid.js/modules/euidIdSystem.js', + 'prebid.js/modules/intentIqIdSystem.js', + 'prebid.js/modules/lotamePanoramaIdSystem.js', + 'prebid.js/modules/connectIdSystem.js', + 'prebid.js/modules/merkleIdSystem.js', + ]; + + // Source-text check: these mocks make the runtime pbjs mock a no-op for the + // User ID Module, so there is no way to assert `typeof getUserIdsAsEids === + // 'function'` at import time from within Vitest. Reading the source file + // directly is the most reliable way to catch accidental removal of an + // import, which is the exact regression that motivated this work. + // Vitest runs with `process.cwd()` set to the package root (`crates/js/lib`) + // where `package.json` lives. + const SOURCE_PATH = resolve(process.cwd(), 'src/integrations/prebid/index.ts'); + const source = readFileSync(SOURCE_PATH, 'utf8'); + + for (const module of REQUIRED_IMPORTS) { + it(`statically imports ${module}`, () => { + const pattern = new RegExp(`import\\s+['"]${module.replace(/\./g, '\\.')}['"]`); + expect(source).toMatch(pattern); + }); + } +}); From 25194f06a3bd832b6a554cfd535c2f7e8c842540 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 12:09:55 -0500 Subject: [PATCH 12/22] =?UTF-8?q?Correct=20Prebid=20User=20ID=20plan=20+?= =?UTF-8?q?=20spec=20=E2=80=94=20drop=20pubCommonIdSystem=20(removed=20in?= =?UTF-8?q?=20Prebid=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-16-prebid-user-id-module.md | 452 +++++++++--------- ...2026-04-16-prebid-user-id-module-design.md | 10 +- 2 files changed, 241 insertions(+), 221 deletions(-) diff --git a/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md b/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md index 9a1e33502..cbf3acaf2 100644 --- a/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md +++ b/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md @@ -4,7 +4,12 @@ **Goal:** Bundle Prebid's User ID core module and a broad set of ID submodules so publisher-side `pbjs.setConfig({ userSync: { userIds: [...] } })` calls activate real ID resolution and the existing `syncPrebidEidsCookie()` helper begins writing `ts-eids`. -**Architecture:** JS-only change. Add 16 static imports (1 core + 15 submodules) to `crates/js/lib/src/integrations/prebid/index.ts`. No Rust changes, no TOML changes, no new runtime logic. Existing `bidsBackHandler` shim and cookie-sync path already handle the rest; they were silent only because `pbjs.getUserIdsAsEids` did not exist. +**Architecture:** JS-only change. Add 15 static imports (1 core + 14 submodules) to `crates/js/lib/src/integrations/prebid/index.ts`. No Rust changes, no TOML changes, no new runtime logic. Existing `bidsBackHandler` shim and cookie-sync path already handle the rest; they were silent only because `pbjs.getUserIdsAsEids` did not exist. + +> **In-flight correction (2026-04-16):** `pubCommonIdSystem.js` was originally +> included in the plan as a legacy/compatibility submodule. It does not exist +> in Prebid 10.26.0 (consolidated into `sharedIdSystem`) and has been removed. +> All downstream counts reflect 14 submodules instead of 15. **Tech Stack:** TypeScript, Vitest, esbuild (via `build-all.mjs`), Prebid.js 9.x (via npm) @@ -14,10 +19,10 @@ ## File Map -| File | Action | Responsibility | -|---|---|---| -| `crates/js/lib/src/integrations/prebid/index.ts` | Modify | Add 16 User ID Module imports alongside existing `consentManagement*` imports | -| `crates/js/lib/test/integrations/prebid/index.test.ts` | Modify | Add `vi.mock` stubs for the 16 new modules, add `mockPbjs.getUserIdsAsEids`, add new tests for cookie-sync behavior and import regression guard | +| File | Action | Responsibility | +| ------------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/js/lib/src/integrations/prebid/index.ts` | Modify | Add 15 User ID Module imports alongside existing `consentManagement*` imports | +| `crates/js/lib/test/integrations/prebid/index.test.ts` | Modify | Add `vi.mock` stubs for the 15 new modules, add `mockPbjs.getUserIdsAsEids`, add new tests for cookie-sync behavior and import regression guard | No other files change. `build.rs` picks up the rebuilt `dist/tsjs-prebid.js` automatically via `include_str!`. @@ -28,6 +33,7 @@ No other files change. `build.rs` picks up the rebuilt `dist/tsjs-prebid.js` aut The sync helper already exists but has no test coverage. Before changing anything, lock in its current contract so we can refactor or extend later without regressions. These tests exercise the `bidsBackHandler` shim path end-to-end using the existing mocks. **Files:** + - Modify: `crates/js/lib/test/integrations/prebid/index.test.ts` - [ ] **Step 1: Add `getUserIdsAsEids` to the hoisted pbjs mock** @@ -45,12 +51,18 @@ const { mockGetBidAdapter, mockAdapterManager, } = vi.hoisted(() => { - const mockSetConfig = vi.fn(); - const mockProcessQueue = vi.fn(); - 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 mockSetConfig = vi.fn() + const mockProcessQueue = vi.fn() + 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, @@ -58,10 +70,10 @@ const { registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, adUnits: [] as any[], - }; + } const mockAdapterManager = { getBidAdapter: mockGetBidAdapter, - }; + } return { mockSetConfig, mockProcessQueue, @@ -71,8 +83,8 @@ const { mockPbjs, mockGetBidAdapter, mockAdapterManager, - }; -}); + } +}) ``` - [ ] **Step 2: Write the failing test — empty EID array writes no cookie** @@ -82,21 +94,21 @@ Append this new `describe` block at the end of `crates/js/lib/test/integrations/ ```ts describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { beforeEach(() => { - vi.clearAllMocks(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); + 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; + ;(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'; - }); + document.cookie = 'ts-eids=; Path=/; Max-Age=0' + }) afterEach(() => { - document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - }); + document.cookie = 'ts-eids=; Path=/; Max-Age=0' + }) /** * Helper: make mockRequestBids actually invoke the injected bidsBackHandler @@ -105,26 +117,28 @@ describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { function wireBidsBackHandler(): void { mockRequestBids.mockImplementation((opts: any) => { if (typeof opts?.bidsBackHandler === 'function') { - opts.bidsBackHandler(); + 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; + 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([]); + wireBidsBackHandler() + const pbjs = installPrebidNpm() + mockGetUserIdsAsEids.mockReturnValue([]) - pbjs.requestBids({ adUnits: [] } as any); + pbjs.requestBids({ adUnits: [] } as any) - expect(getTsEidsCookie()).toBeUndefined(); - }); -}); + expect(getTsEidsCookie()).toBeUndefined() + }) +}) ``` - [ ] **Step 3: Run the test — expect PASS (documents existing behavior)** @@ -138,24 +152,24 @@ Expected: PASS. The shim already calls `syncPrebidEidsCookie` which early-return Append inside the same `describe` block: ```ts - 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('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 }, + ]) +}) ``` - [ ] **Step 5: Run and confirm PASS** @@ -169,18 +183,18 @@ Expected: PASS. Append: ```ts - 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('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 }]) +}) ``` - [ ] **Step 7: Add test — skips entries without an id or source** @@ -188,21 +202,21 @@ Append: Append: ```ts - 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('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 }]) +}) ``` - [ ] **Step 8: Add test — takes first uid when multiple are present** @@ -210,24 +224,24 @@ Append: Append: ```ts - 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('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 }]) +}) ``` - [ ] **Step 9: Add test — trims tail when payload exceeds 3072 bytes** @@ -235,31 +249,31 @@ Append: Append: ```ts - 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('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') +}) ``` - [ ] **Step 10: Add test — writes no cookie when a single entry alone exceeds the cap** @@ -267,19 +281,19 @@ Append: Append: ```ts - it('writes no cookie when a single entry alone exceeds the cap', () => { - wireBidsBackHandler(); - const pbjs = installPrebidNpm(); +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 }] }, - ]); + // 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); + pbjs.requestBids({ adUnits: [] } as any) - expect(getTsEidsCookie()).toBeUndefined(); - }); + expect(getTsEidsCookie()).toBeUndefined() +}) ``` - [ ] **Step 11: Add test — does not throw when getUserIdsAsEids is undefined** @@ -287,18 +301,18 @@ Append: This mirrors the pre-fix production state and guards against regressions in the defensive check at `index.ts:375`. Append: ```ts - 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('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 +}) ``` - [ ] **Step 12: Add test — calls the original bidsBackHandler when one was supplied** @@ -306,15 +320,15 @@ This mirrors the pre-fix production state and guards against regressions in the Append: ```ts - it('calls the original bidsBackHandler after syncing EIDs', () => { - wireBidsBackHandler(); - const pbjs = installPrebidNpm(); - const originalHandler = vi.fn(); +it('calls the original bidsBackHandler after syncing EIDs', () => { + wireBidsBackHandler() + const pbjs = installPrebidNpm() + const originalHandler = vi.fn() - pbjs.requestBids({ adUnits: [], bidsBackHandler: originalHandler } as any); + pbjs.requestBids({ adUnits: [], bidsBackHandler: originalHandler } as any) - expect(originalHandler).toHaveBeenCalledTimes(1); - }); + expect(originalHandler).toHaveBeenCalledTimes(1) +}) ``` - [ ] **Step 13: Run the full new block and confirm all pass** @@ -343,46 +357,47 @@ git commit -m "Add Vitest coverage for Prebid ts-eids cookie sync" This is the substantive change. Add `vi.mock` stubs for the new modules first (so tests don't blow up when the imports are added), then add the imports. **Files:** + - Modify: `crates/js/lib/test/integrations/prebid/index.test.ts:44-47` - Modify: `crates/js/lib/src/integrations/prebid/index.ts:16-18` -- [ ] **Step 1: Add `vi.mock` stubs in the test file for all 16 new modules** +- [ ] **Step 1: Add `vi.mock` stubs in the test file for all 15 new modules** In `crates/js/lib/test/integrations/prebid/index.test.ts`, locate the existing block (around line 44-47): ```ts // Side-effect imports are no-ops in tests -vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); +vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})) +vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})) +vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})) ``` -Replace it with (consent management mocks stay; add 16 new ones): +Replace it with (consent management mocks stay; add 15 new ones): ```ts // Side-effect imports are no-ops in tests -vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); +vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})) +vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})) +vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})) // User ID Module core + submodules — no-op mocks so jsdom does not try to // execute the real Prebid code paths. -vi.mock('prebid.js/modules/userId.js', () => ({})); -vi.mock('prebid.js/modules/sharedIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/criteoIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/33acrossIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/pubProvidedIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/quantcastIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/id5IdSystem.js', () => ({})); -vi.mock('prebid.js/modules/identityLinkIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/liveIntentIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/uid2IdSystem.js', () => ({})); -vi.mock('prebid.js/modules/euidIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/intentIqIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/lotamePanoramaIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/connectIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/merkleIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/pubCommonIdSystem.js', () => ({})); +vi.mock('prebid.js/modules/userId.js', () => ({})) +vi.mock('prebid.js/modules/sharedIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/criteoIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/33acrossIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/pubProvidedIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/quantcastIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/id5IdSystem.js', () => ({})) +vi.mock('prebid.js/modules/identityLinkIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/liveIntentIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/uid2IdSystem.js', () => ({})) +vi.mock('prebid.js/modules/euidIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/intentIqIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/lotamePanoramaIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/connectIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/merkleIdSystem.js', () => ({})) +vi.mock('prebid.js/modules/pubCommonIdSystem.js', () => ({})) ``` - [ ] **Step 2: Write the failing regression-guard test** @@ -390,9 +405,9 @@ vi.mock('prebid.js/modules/pubCommonIdSystem.js', () => ({})); Append this `describe` block to `crates/js/lib/test/integrations/prebid/index.test.ts`: ```ts -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { resolve } from 'node:path'; +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' describe('prebid/index.ts User ID Module imports (regression guard)', () => { const REQUIRED_IMPORTS = [ @@ -412,7 +427,7 @@ describe('prebid/index.ts User ID Module imports (regression guard)', () => { 'prebid.js/modules/connectIdSystem.js', 'prebid.js/modules/merkleIdSystem.js', 'prebid.js/modules/pubCommonIdSystem.js', - ]; + ] // Source-text check: these mocks make the runtime pbjs mock a no-op for the // User ID Module, so there is no way to assert `typeof getUserIdsAsEids === @@ -421,75 +436,80 @@ describe('prebid/index.ts User ID Module imports (regression guard)', () => { // import, which is the exact regression that motivated this work. // The package is ESM (`"type": "module"`), so `__dirname` is not defined — // resolve relative to this file via `import.meta.url`. - const THIS_DIR = fileURLToPath(new URL('.', import.meta.url)); - const SOURCE_PATH = resolve(THIS_DIR, '../../../src/integrations/prebid/index.ts'); - const source = readFileSync(SOURCE_PATH, 'utf8'); + const THIS_DIR = fileURLToPath(new URL('.', import.meta.url)) + const SOURCE_PATH = resolve( + THIS_DIR, + '../../../src/integrations/prebid/index.ts' + ) + const source = readFileSync(SOURCE_PATH, 'utf8') for (const module of REQUIRED_IMPORTS) { it(`statically imports ${module}`, () => { - const pattern = new RegExp(`import\\s+['"]${module.replace(/\./g, '\\.')}['"]`); - expect(source).toMatch(pattern); - }); + const pattern = new RegExp( + `import\\s+['"]${module.replace(/\./g, '\\.')}['"]` + ) + expect(source).toMatch(pattern) + }) } -}); +}) ``` -- [ ] **Step 3: Run the new block — expect 16 failures** +- [ ] **Step 3: Run the new block — expect 15 failures** Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "regression guard"` -Expected: FAIL — 16 failing assertions, one per expected import. This confirms the regression guard actually reads the source. +Expected: FAIL — 15 failing assertions, one per expected import. This confirms the regression guard actually reads the source. -- [ ] **Step 4: Add the 16 imports to `index.ts`** +- [ ] **Step 4: Add the 15 imports to `index.ts`** In `crates/js/lib/src/integrations/prebid/index.ts`, locate lines 16-18: ```ts -import 'prebid.js/modules/consentManagementTcf.js'; -import 'prebid.js/modules/consentManagementGpp.js'; -import 'prebid.js/modules/consentManagementUsp.js'; +import 'prebid.js/modules/consentManagementTcf.js' +import 'prebid.js/modules/consentManagementGpp.js' +import 'prebid.js/modules/consentManagementUsp.js' ``` Insert the User ID imports immediately after them, before the existing `// Client-side bid adapters` comment block. The resulting section must read: ```ts -import 'prebid.js/modules/consentManagementTcf.js'; -import 'prebid.js/modules/consentManagementGpp.js'; -import 'prebid.js/modules/consentManagementUsp.js'; +import 'prebid.js/modules/consentManagementTcf.js' +import 'prebid.js/modules/consentManagementGpp.js' +import 'prebid.js/modules/consentManagementUsp.js' // Prebid User ID Module — core + submodules. The core module exposes // `pbjs.getUserIdsAsEids`; submodules self-register at import time and // activate when the publisher's origin-side `pbjs.setConfig({ userSync: // { userIds: [...] } })` call runs during `processQueue()`. -import 'prebid.js/modules/userId.js'; +import 'prebid.js/modules/userId.js' // Zero-config / auto-populating submodules (resolve without publisher params). -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/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' // Param-based submodules — inert until publisher setConfig supplies params. -import 'prebid.js/modules/id5IdSystem.js'; -import 'prebid.js/modules/identityLinkIdSystem.js'; -import 'prebid.js/modules/liveIntentIdSystem.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'; +import 'prebid.js/modules/id5IdSystem.js' +import 'prebid.js/modules/identityLinkIdSystem.js' +import 'prebid.js/modules/liveIntentIdSystem.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' // Legacy / deprecated but still present in some publisher configs. -import 'prebid.js/modules/pubCommonIdSystem.js'; +import 'prebid.js/modules/pubCommonIdSystem.js' ``` - [ ] **Step 5: Run the regression-guard block — expect PASS** Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "regression guard"` -Expected: all 16 tests PASS. +Expected: all 15 tests PASS. - [ ] **Step 6: Run the full prebid test file — expect no regressions** @@ -501,7 +521,7 @@ Expected: all tests PASS (Task 1 tests + regression guards + all pre-existing te Run: `cd crates/js/lib && npx vitest run` -Expected: all PASS. If any unrelated test fails because it imports `./index.ts` transitively and the real Prebid modules are not mocked there, add matching `vi.mock` stubs at the top of that test file (copy the same 16 lines). Common suspects: any test under `crates/js/lib/test/integrations/prebid/` you may have added, or tests that import core modules which in turn import the prebid integration. At the time of writing, no other test file imports the prebid integration. +Expected: all PASS. If any unrelated test fails because it imports `./index.ts` transitively and the real Prebid modules are not mocked there, add matching `vi.mock` stubs at the top of that test file (copy the same 15 lines). Common suspects: any test under `crates/js/lib/test/integrations/prebid/` you may have added, or tests that import core modules which in turn import the prebid integration. At the time of writing, no other test file imports the prebid integration. - [ ] **Step 8: Build the JS bundles** diff --git a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md index bc0f3f669..45341a62c 100644 --- a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md +++ b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md @@ -67,12 +67,12 @@ the relevant params): - `prebid.js/modules/connectIdSystem.js` - `prebid.js/modules/merkleIdSystem.js` -**Legacy / compatibility:** +Total: 1 core + 14 submodules = 15 new imports. -- `prebid.js/modules/pubCommonIdSystem.js` — deprecated in favor of SharedID - but still present in some publisher configs. - -Total: 1 core + 15 submodules = 16 new imports. +> **Note (2026-04-16, during implementation):** `pubCommonIdSystem.js`, which +> was originally planned as a legacy/compatibility submodule, was removed from +> Prebid.js in 10.x (consolidated into `sharedIdSystem`). It is not importable +> from our pinned Prebid 10.26.0 and has been dropped from this plan. No changes to `installPrebidNpm`, no changes to the `bidsBackHandler` shim, no changes to `syncPrebidEidsCookie`. The existing cookie-writing path is already From 6f5644ef7e404327332d7041737b1891fffbc2bb Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 12:45:32 -0500 Subject: [PATCH 13/22] Drop liveIntentIdSystem from Prebid bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prebid's liveIntentIdSystem.js uses a dynamic require() inside a build-flag-guarded branch that their gulp pipeline dead-codes via constant folding. esbuild leaves the require() in the output, causing ReferenceError: require is not defined at browser runtime. Remove from the bundle until we add an esbuild resolver plugin (or switch to Prebid's own build pipeline) — tracked as a follow-up in the design spec. --- .../js/lib/src/integrations/prebid/index.ts | 7 +++++- .../test/integrations/prebid/index.test.ts | 2 -- ...2026-04-16-prebid-user-id-module-design.md | 23 ++++++++++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 99ac3fb82..7c77aa579 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -34,7 +34,12 @@ import 'prebid.js/modules/quantcastIdSystem.js'; // Param-based submodules — inert until publisher setConfig supplies params. import 'prebid.js/modules/id5IdSystem.js'; import 'prebid.js/modules/identityLinkIdSystem.js'; -import 'prebid.js/modules/liveIntentIdSystem.js'; +// NOTE: `liveIntentIdSystem.js` is intentionally not imported. Its upstream +// module 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()` call in the bundle, which throws at browser runtime. Re- +// enabling it requires an esbuild resolver plugin (or switching to Prebid's +// own build pipeline). Tracked as a follow-up in the design spec. import 'prebid.js/modules/uid2IdSystem.js'; import 'prebid.js/modules/euidIdSystem.js'; import 'prebid.js/modules/intentIqIdSystem.js'; diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 30156d9b7..7546183ba 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -66,7 +66,6 @@ vi.mock('prebid.js/modules/pubProvidedIdSystem.js', () => ({})); vi.mock('prebid.js/modules/quantcastIdSystem.js', () => ({})); vi.mock('prebid.js/modules/id5IdSystem.js', () => ({})); vi.mock('prebid.js/modules/identityLinkIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/liveIntentIdSystem.js', () => ({})); vi.mock('prebid.js/modules/uid2IdSystem.js', () => ({})); vi.mock('prebid.js/modules/euidIdSystem.js', () => ({})); vi.mock('prebid.js/modules/intentIqIdSystem.js', () => ({})); @@ -1177,7 +1176,6 @@ describe('prebid/index.ts User ID Module imports (regression guard)', () => { 'prebid.js/modules/quantcastIdSystem.js', 'prebid.js/modules/id5IdSystem.js', 'prebid.js/modules/identityLinkIdSystem.js', - 'prebid.js/modules/liveIntentIdSystem.js', 'prebid.js/modules/uid2IdSystem.js', 'prebid.js/modules/euidIdSystem.js', 'prebid.js/modules/intentIqIdSystem.js', diff --git a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md index 45341a62c..2c59005ee 100644 --- a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md +++ b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md @@ -59,7 +59,6 @@ the relevant params): - `prebid.js/modules/id5IdSystem.js` - `prebid.js/modules/identityLinkIdSystem.js` -- `prebid.js/modules/liveIntentIdSystem.js` - `prebid.js/modules/uid2IdSystem.js` - `prebid.js/modules/euidIdSystem.js` - `prebid.js/modules/intentIqIdSystem.js` @@ -67,12 +66,19 @@ the relevant params): - `prebid.js/modules/connectIdSystem.js` - `prebid.js/modules/merkleIdSystem.js` -Total: 1 core + 14 submodules = 15 new imports. +Total: 1 core + 13 submodules = 14 new imports. -> **Note (2026-04-16, during implementation):** `pubCommonIdSystem.js`, which -> was originally planned as a legacy/compatibility submodule, was removed from -> Prebid.js in 10.x (consolidated into `sharedIdSystem`). It is not importable -> from our pinned Prebid 10.26.0 and has been dropped from this plan. +> **Notes (2026-04-16, during implementation):** +> +> - `pubCommonIdSystem.js`, originally planned as a legacy/compatibility +> submodule, was removed from Prebid.js in 10.x (consolidated into +> `sharedIdSystem`). Not importable from our pinned Prebid 10.26.0; dropped. +> - `liveIntentIdSystem.js` was dropped from the day-1 bundle. Its upstream +> module 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()` call in the bundle, which throws `ReferenceError: require +> is not defined` at browser runtime. Re-enabling requires an esbuild +> resolver plugin (or Prebid's own build pipeline). Tracked as a follow-up. No changes to `installPrebidNpm`, no changes to the `bidsBackHandler` shim, no changes to `syncPrebidEidsCookie`. The existing cookie-writing path is already @@ -196,3 +202,8 @@ auction. Publishers without `userSync.userIds` configured see no change. 3. **Partner alignment tooling** — a startup-time check that warns when a bundled ID submodule has no matching `[[ec.partners]]` entry, or vice versa. +4. **Re-enable `liveIntentIdSystem.js`** — requires either an esbuild + resolver plugin that rewrites the dynamic `require('../libraries/ + liveIntentId/idSystem.js')` inside `loadModule()` to a static import, or + adopting Prebid's own gulp build pipeline for the vendored bundle. + Out-of-scope for the initial ship. From 3cae39cf9aa3695ca81940052eef33a65bc44127 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 15:47:05 -0500 Subject: [PATCH 14/22] Make Prebid User ID submodule set configurable at build time Introduces TSJS_PREBID_USER_IDS env var (mirroring TSJS_PREBID_ADAPTERS) to control which Prebid User ID submodules are bundled. The hardcoded imports in index.ts are replaced with a generated file written by build-all.mjs at build time, defaulting to the same 13-submodule set. - build-all.mjs: generatePrebidUserIds() validates names, denylists liveIntentIdSystem, and writes _user_ids.generated.ts. Existence check also probes dist/src/public/ to handle modules shipped as .ts in sources (sharedIdSystem). - index.ts: replaces 13 hardcoded submodule imports with import './_user_ids.generated' - _user_ids.generated.ts: committed default with all 13 submodules - Tests: updated mocks and regression guard; added 9 syncPrebidEidsCookie behavior tests - Docs: new "User ID Modules" section in prebid.md with TSJS_PREBID_USER_IDS usage; spec follow-up #1 marked complete --- crates/js/lib/build-all.mjs | 132 ++++++++++++++++++ .../prebid/_user_ids.generated.ts | 22 +++ .../js/lib/src/integrations/prebid/index.ts | 35 ++--- .../test/integrations/prebid/index.test.ts | 93 ++++++------ docs/guide/integrations/prebid.md | 37 +++++ ...2026-04-16-prebid-user-id-module-design.md | 7 +- 6 files changed, 251 insertions(+), 75 deletions(-) create mode 100644 crates/js/lib/src/integrations/prebid/_user_ids.generated.ts diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index cc5690e0c..b1a650b80 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/integrations/prebid/_user_ids.generated.ts b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts new file mode 100644 index 000000000..9eb586ddd --- /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 7c77aa579..7d8118912 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -18,34 +18,17 @@ import 'prebid.js/modules/consentManagementGpp.js'; import 'prebid.js/modules/consentManagementUsp.js'; import 'prebid.js/modules/userId.js'; -// Prebid User ID Module — core + submodules. The core module exposes -// `pbjs.getUserIdsAsEids`; submodules self-register at import time and -// activate when the publisher's origin-side `pbjs.setConfig({ userSync: -// { userIds: [...] } })` call runs during `processQueue()`. +// 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'; -// Zero-config / auto-populating submodules (resolve without publisher params). -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'; - -// Param-based submodules — inert until publisher setConfig supplies params. -import 'prebid.js/modules/id5IdSystem.js'; -import 'prebid.js/modules/identityLinkIdSystem.js'; -// NOTE: `liveIntentIdSystem.js` is intentionally not imported. Its upstream -// module 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()` call in the bundle, which throws at browser runtime. Re- -// enabling it requires an esbuild resolver plugin (or switching to Prebid's -// own build pipeline). Tracked as a follow-up in the design spec. -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'; +// 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 diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 7546183ba..c4c80b0f4 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -56,25 +56,13 @@ vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); vi.mock('prebid.js/modules/userId.js', () => ({})); -// User ID Module core + submodules — no-op mocks so jsdom does not try to -// execute the real Prebid code paths. +// 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', () => ({})); -vi.mock('prebid.js/modules/sharedIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/criteoIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/33acrossIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/pubProvidedIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/quantcastIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/id5IdSystem.js', () => ({})); -vi.mock('prebid.js/modules/identityLinkIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/uid2IdSystem.js', () => ({})); -vi.mock('prebid.js/modules/euidIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/intentIqIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/lotamePanoramaIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/connectIdSystem.js', () => ({})); -vi.mock('prebid.js/modules/merkleIdSystem.js', () => ({})); - -// Mock the build-generated adapter imports (no-op in tests) + +// 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, @@ -1166,38 +1154,49 @@ describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { }); }); -describe('prebid/index.ts User ID Module imports (regression guard)', () => { - const REQUIRED_IMPORTS = [ - 'prebid.js/modules/userId.js', - 'prebid.js/modules/sharedIdSystem.js', - 'prebid.js/modules/criteoIdSystem.js', - 'prebid.js/modules/33acrossIdSystem.js', - 'prebid.js/modules/pubProvidedIdSystem.js', - 'prebid.js/modules/quantcastIdSystem.js', - 'prebid.js/modules/id5IdSystem.js', - 'prebid.js/modules/identityLinkIdSystem.js', - 'prebid.js/modules/uid2IdSystem.js', - 'prebid.js/modules/euidIdSystem.js', - 'prebid.js/modules/intentIqIdSystem.js', - 'prebid.js/modules/lotamePanoramaIdSystem.js', - 'prebid.js/modules/connectIdSystem.js', - 'prebid.js/modules/merkleIdSystem.js', +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', ]; - // Source-text check: these mocks make the runtime pbjs mock a no-op for the - // User ID Module, so there is no way to assert `typeof getUserIdsAsEids === - // 'function'` at import time from within Vitest. Reading the source file - // directly is the most reliable way to catch accidental removal of an - // import, which is the exact regression that motivated this work. - // Vitest runs with `process.cwd()` set to the package root (`crates/js/lib`) - // where `package.json` lives. - const SOURCE_PATH = resolve(process.cwd(), 'src/integrations/prebid/index.ts'); - const source = readFileSync(SOURCE_PATH, 'utf8'); - - for (const module of REQUIRED_IMPORTS) { - it(`statically imports ${module}`, () => { - const pattern = new RegExp(`import\\s+['"]${module.replace(/\./g, '\\.')}['"]`); - expect(source).toMatch(pattern); + 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/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index f7b7a910e..2be659fdc 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -223,6 +223,43 @@ The build script (`build-all.mjs`) validates that each adapter exists in `prebid Adding a new client-side bidder requires both a config change (`client_side_bidders`) **and** a rebuild with the adapter included in `TSJS_PREBID_ADAPTERS`. Without the adapter in the bundle, the bidder is silently dropped from both server-side and client-side auctions. ::: +## User ID Modules + +Prebid's User ID Module resolves cross-publisher identifiers (SharedID, ID5, LiveRamp RampID, UID2, etc.) and exposes them via `pbjs.getUserIdsAsEids()`. The TSJS Prebid integration bundles the core `userId.js` module and a configurable set of ID submodules. When the publisher's origin-side `pbjs.setConfig({ userSync: { userIds: [...] } })` call runs during `processQueue()`, each listed submodule activates and begins resolving its ID asynchronously. After each auction the shim writes the resolved EIDs to a `ts-eids` cookie, which the Rust backend ingests into the Edge Cookie identity graph. + +### How it works + +1. `userId.js` is statically imported in `index.ts` — always bundled, not operator-configurable. +2. The set of ID submodules is controlled by `TSJS_PREBID_USER_IDS` at build time and emitted into `_user_ids.generated.ts`. +3. Publishers retain full control of which submodules actually run — activation is driven by their own `pbjs.setConfig({ userSync: { userIds: [...] } })` on origin. Bundling a submodule without a matching publisher config entry is inert (but costs bundle size). +4. The `bidsBackHandler` shim calls `pbjs.getUserIdsAsEids()` after each auction and writes the resolved entries to the `ts-eids` cookie (base64-encoded JSON, 3072-byte cap with tail-trim). + +### Build-time submodule selection + +```bash +# Default: the full ship-set of 13 submodules +# (sharedIdSystem, criteoIdSystem, 33acrossIdSystem, pubProvidedIdSystem, +# quantcastIdSystem, id5IdSystem, identityLinkIdSystem, uid2IdSystem, +# euidIdSystem, intentIqIdSystem, lotamePanoramaIdSystem, connectIdSystem, +# merkleIdSystem) + +# Slim build — only SharedID and ID5 +TSJS_PREBID_USER_IDS=sharedIdSystem,id5IdSystem + +# Single submodule +TSJS_PREBID_USER_IDS=sharedIdSystem +``` + +Values are Prebid module filenames without the `.js` extension. The build script (`build-all.mjs`) validates that each exists in `prebid.js/modules/{name}.js` and generates `_user_ids.generated.ts` with the appropriate imports. Unknown names log a warning and are skipped. + +::: warning +`liveIntentIdSystem` is on a build-time denylist — its upstream module uses a dynamic `require()` that esbuild cannot statically resolve, throwing `ReferenceError: require is not defined` at browser runtime. Listing it in `TSJS_PREBID_USER_IDS` logs a warning and skips the module. +::: + +::: tip +Each bundled submodule bloats `tsjs-prebid.js`. If a publisher deployment only needs SharedID and ID5, set `TSJS_PREBID_USER_IDS` accordingly — the other ~100kb of dormant module code won't ship. +::: + ## Identity Forwarding Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions: diff --git a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md index 2c59005ee..2ef80a3eb 100644 --- a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md +++ b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md @@ -190,10 +190,13 @@ auction. Publishers without `userSync.userIds` configured see no change. ## Follow-ups -1. **Build-time configurability** — introduce `_user_ids.generated.ts` +1. ~~**Build-time configurability** — introduce `_user_ids.generated.ts` driven by a `TSJS_PREBID_USER_IDS` env var, mirroring the existing `TSJS_PREBID_ADAPTERS` / `_adapters.generated.ts` pattern. Allows - operators to slim the bundle per deployment. + operators to slim the bundle per deployment.~~ **Implemented + 2026-04-16** — see `docs/guide/integrations/prebid.md` "Build-time + submodule selection" and the `generatePrebidUserIds()` function in + `crates/js/lib/build-all.mjs`. 2. **Server-injected `userSync.userIds`** — extend `trusted-server.toml` with a `[[integrations.prebid.user_ids]]` array. Rust serializes into `window.__tsjs_prebid.userIds`. JS applies via `pbjs.setConfig` before From b8dbf2ea8bdadc9133901b1d0ff0a1dc56f5fc2d Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 16:10:08 -0500 Subject: [PATCH 15/22] Clear stale consent cookies and aggregate US GPP opt-outs --- .../js/lib/src/integrations/prebid/index.ts | 1 + .../lib/src/integrations/sourcepoint/index.ts | 22 +- .../test/integrations/prebid/index.test.ts | 53 ++++ .../integrations/sourcepoint/index.test.ts | 64 ++++- crates/trusted-server-core/src/consent/gpp.rs | 227 ++++++++++++++---- 5 files changed, 318 insertions(+), 49 deletions(-) diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 7d8118912..a9a561de9 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -463,6 +463,7 @@ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { function syncPrebidEidsCookie(): void { try { if (typeof pbjs.getUserIdsAsEids !== 'function') { + clearPrebidEidsCookie(); return; } diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts index 850659dcf..84295f8a5 100644 --- a/crates/js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -1,6 +1,8 @@ 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; @@ -20,10 +22,12 @@ function findSourcepointConsent(): SourcepointConsentPayload | null { if (!raw) continue; try { - return JSON.parse(raw) as SourcepointConsentPayload; + 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; } } return null; @@ -33,6 +37,10 @@ function writeCookie(name: string, value: string): void { document.cookie = `${name}=${value}; path=/; SameSite=Lax`; } +function clearCookie(name: string): void { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`; +} + /** * Reads Sourcepoint consent from localStorage and mirrors it into * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. @@ -46,20 +54,26 @@ export function mirrorSourcepointConsent(): boolean { 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', gppString); + writeCookie(GPP_COOKIE_NAME, gppString); if (Array.isArray(applicableSections) && applicableSections.length > 0) { - writeCookie('__gpp_sid', applicableSections.join(',')); + writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); + } else { + clearCookie(GPP_SID_COOKIE_NAME); } log.info('sourcepoint: mirrored GPP consent to cookies', { diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index c4c80b0f4..60df059cd 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -1025,6 +1025,17 @@ describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { 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(); @@ -1130,6 +1141,34 @@ describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { 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(); @@ -1143,6 +1182,20 @@ describe('prebid/syncPrebidEidsCookie (via bidsBackHandler)', () => { (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(); diff --git a/crates/js/lib/test/integrations/sourcepoint/index.test.ts b/crates/js/lib/test/integrations/sourcepoint/index.test.ts index 4eaf763f6..872585c32 100644 --- a/crates/js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -3,16 +3,26 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; describe('integrations/sourcepoint', () => { - beforeEach(() => { - // Clear cookies and localStorage before each test. + 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(); }); @@ -56,6 +66,18 @@ describe('integrations/sourcepoint', () => { 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!!!'); @@ -65,6 +87,25 @@ describe('integrations/sourcepoint', () => { 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 })); @@ -88,4 +129,23 @@ describe('integrations/sourcepoint', () => { 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 d6e8902ed..cb704b029 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -111,56 +111,73 @@ fn decode_tcf_from_gpp(parsed: &iab_gpp::v1::GPPString) -> Option { /// 21=UsNj, 22=UsTn, 23=UsMn. const US_SECTION_ID_RANGE: std::ops::RangeInclusive = 7..=23; -/// Extracts the `sale_opt_out` signal from the first US section in a parsed -/// GPP string. +/// 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). -/// For the first match, decodes the section and extracts `sale_opt_out`. +/// Iterates through section IDs looking for any in the US range (7–23), +/// decodes each US section, and aggregates the result conservatively: /// -/// Returns `Some(true)` if the user opted out of sale, `Some(false)` if they -/// did not, or `None` if no US section is present. +/// - `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 { - use iab_gpp::sections::us_common::OptOut; - use iab_gpp::sections::Section; + let mut saw_not_opted_out = false; - let us_section_id = parsed + for us_section_id in parsed .section_ids() - .find(|id| US_SECTION_ID_RANGE.contains(&(**id as u16)))?; - - match parsed.decode_section(*us_section_id) { - Ok(section) => { - let sale_opt_out = match §ion { - Section::UsNat(s) => match &s.core { - iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, - iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out, - _ => 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, - // Non-US sections — should not reach here given the ID filter. - _ => return None, - }; - Some(*sale_opt_out == OptOut::OptedOut) - } - Err(e) => { - log::warn!("Failed to decode US GPP section {us_section_id}: {e}"); - None + .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. @@ -320,6 +337,70 @@ mod tests { } } + 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"); @@ -328,4 +409,64 @@ mod tests { "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" + ); + } } From 00cc54bb00d7e1631ab9039ff9499b7425fa5dbe Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 16:46:49 -0500 Subject: [PATCH 16/22] Add Secure flag and Max-Age to Sourcepoint GPP cookies __gpp and __gpp_sid are read by the Rust server over HTTPS; they must be Secure. Also sets Max-Age=86400 (matching ts-eids) so stale consent state doesn't outlast the session, and replaces the legacy expires= deletion pattern with Max-Age=0. --- crates/js/lib/src/integrations/sourcepoint/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts index 84295f8a5..e7024b625 100644 --- a/crates/js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -33,12 +33,14 @@ function findSourcepointConsent(): SourcepointConsentPayload | null { return null; } +const GPP_COOKIE_MAX_AGE = 86400; + function writeCookie(name: string, value: string): void { - document.cookie = `${name}=${value}; path=/; SameSite=Lax`; + document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax; Max-Age=${GPP_COOKIE_MAX_AGE}`; } function clearCookie(name: string): void { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`; + document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; } /** From 275e892abd4cf33b3256b4ca12a3b7115cb27c5e Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 17 Apr 2026 16:12:35 -0500 Subject: [PATCH 17/22] support ec partners map for env overrides --- crates/trusted-server-core/src/settings.rs | 212 ++++++++++++++++++--- 1 file changed, 188 insertions(+), 24 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 095c8afa7..2334f6484 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -98,6 +98,27 @@ pub trait IntegrationConfig: DeserializeOwned + Validate { fn is_enabled(&self) -> bool; } +fn normalize_env_value(value: JsonValue) -> JsonValue { + match value { + JsonValue::Object(map) => JsonValue::Object( + map.into_iter() + .map(|(key, val)| (key, normalize_env_value(val))) + .collect(), + ), + JsonValue::Array(items) => { + JsonValue::Array(items.into_iter().map(normalize_env_value).collect()) + } + JsonValue::String(raw) => { + if let Ok(parsed) = serde_json::from_str::(&raw) { + parsed + } else { + JsonValue::String(raw) + } + } + other => other, + } +} + impl IntegrationSettings { /// Inserts a configuration value for an integration. /// @@ -121,34 +142,13 @@ impl IntegrationSettings { Ok(()) } - fn normalize_env_value(value: JsonValue) -> JsonValue { - match value { - JsonValue::Object(map) => JsonValue::Object( - map.into_iter() - .map(|(key, val)| (key, Self::normalize_env_value(val))) - .collect(), - ), - JsonValue::Array(items) => { - JsonValue::Array(items.into_iter().map(Self::normalize_env_value).collect()) - } - JsonValue::String(raw) => { - if let Ok(parsed) = serde_json::from_str::(&raw) { - parsed - } else { - JsonValue::String(raw) - } - } - other => other, - } - } - /// Normalizes all entries in place, converting JSON-encoded strings from /// environment variables into their proper typed representations. /// Called eagerly after deserialization so that TOML serialization in /// build.rs preserves correct types. pub fn normalize(&mut self) { for value in self.entries.values_mut() { - *value = Self::normalize_env_value(value.clone()); + *value = normalize_env_value(value.clone()); } } @@ -989,7 +989,9 @@ where match v { JsonValue::Array(arr) => arr .into_iter() - .map(|item| serde_json::from_value(item).map_err(serde::de::Error::custom)) + .map(|item| { + serde_json::from_value(normalize_env_value(item)).map_err(serde::de::Error::custom) + }) .collect(), JsonValue::Object(map) => { let mut items: Vec<(usize, T)> = Vec::with_capacity(map.len()); @@ -997,7 +999,8 @@ where let idx = k.parse::().map_err(|_| { serde::de::Error::custom(format!("Invalid index '{}' in map for Vec field", k)) })?; - let parsed: T = serde_json::from_value(val).map_err(serde::de::Error::custom)?; + let parsed: T = serde_json::from_value(normalize_env_value(val)) + .map_err(serde::de::Error::custom)?; items.push((idx, parsed)); } items.sort_by_key(|(idx, _)| *idx); @@ -1699,6 +1702,167 @@ mod tests { ); } + #[test] + fn test_ec_partners_override_with_indexed_env() { + let toml_str = crate_test_settings_str(); + + let origin_key = format!( + "{}{}PUBLISHER{}ORIGIN_URL", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner0_id_key = format!( + "{}{}EC{}PARTNERS{}0{}ID", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner0_name_key = format!( + "{}{}EC{}PARTNERS{}0{}NAME", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner0_source_domain_key = format!( + "{}{}EC{}PARTNERS{}0{}SOURCE_DOMAIN", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner0_openrtb_atype_key = format!( + "{}{}EC{}PARTNERS{}0{}OPENRTB_ATYPE", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner0_bidstream_enabled_key = format!( + "{}{}EC{}PARTNERS{}0{}BIDSTREAM_ENABLED", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner0_api_token_key = format!( + "{}{}EC{}PARTNERS{}0{}API_TOKEN", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner1_id_key = format!( + "{}{}EC{}PARTNERS{}1{}ID", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner1_name_key = format!( + "{}{}EC{}PARTNERS{}1{}NAME", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner1_source_domain_key = format!( + "{}{}EC{}PARTNERS{}1{}SOURCE_DOMAIN", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner1_openrtb_atype_key = format!( + "{}{}EC{}PARTNERS{}1{}OPENRTB_ATYPE", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner1_bidstream_enabled_key = format!( + "{}{}EC{}PARTNERS{}1{}BIDSTREAM_ENABLED", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let partner1_api_token_key = format!( + "{}{}EC{}PARTNERS{}1{}API_TOKEN", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + + temp_env::with_vars( + [ + (origin_key, Some("https://origin.test-publisher.com")), + (partner0_id_key, Some("criteo")), + (partner0_name_key, Some("Criteo")), + (partner0_source_domain_key, Some("criteo.com")), + (partner0_openrtb_atype_key, Some("1")), + (partner0_bidstream_enabled_key, Some("true")), + (partner0_api_token_key, Some("criteo-test-token")), + (partner1_id_key, Some("uidapi")), + (partner1_name_key, Some("UID2")), + (partner1_source_domain_key, Some("uidapi.com")), + (partner1_openrtb_atype_key, Some("3")), + (partner1_bidstream_enabled_key, Some("true")), + (partner1_api_token_key, Some("uidapi-test-token")), + ], + || { + let settings = Settings::from_toml_and_env(&toml_str) + .expect("should parse EC partners from indexed env override"); + + assert_eq!( + settings.ec.partners.len(), + 2, + "should override partner list" + ); + assert_eq!(settings.ec.partners[0].id, "criteo"); + assert_eq!(settings.ec.partners[0].name, "Criteo"); + assert_eq!(settings.ec.partners[0].source_domain, "criteo.com"); + assert_eq!(settings.ec.partners[0].openrtb_atype, 1); + assert!( + settings.ec.partners[0].bidstream_enabled, + "should enable bidstream for partner 0" + ); + assert_eq!( + settings.ec.partners[0].api_token.expose(), + "criteo-test-token" + ); + + assert_eq!(settings.ec.partners[1].id, "uidapi"); + assert_eq!(settings.ec.partners[1].name, "UID2"); + assert_eq!(settings.ec.partners[1].source_domain, "uidapi.com"); + assert_eq!(settings.ec.partners[1].openrtb_atype, 3); + assert!( + settings.ec.partners[1].bidstream_enabled, + "should enable bidstream for partner 1" + ); + assert_eq!( + settings.ec.partners[1].api_token.expose(), + "uidapi-test-token" + ); + }, + ); + } + #[test] fn test_settings_extra_fields() { let toml_str = crate_test_settings_str() + "\nhello = 1"; From be9a930367b2ca9f309f72f1a9a85febfe44564c Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 21 Apr 2026 08:45:18 -0500 Subject: [PATCH 18/22] Scope Sourcepoint consent PR and address review feedback --- crates/js/lib/build-all.mjs | 132 ---- .../js/lib/src/integrations/prebid/index.ts | 7 - .../lib/src/integrations/sourcepoint/index.ts | 8 +- .../test/integrations/prebid/index.test.ts | 284 +-------- .../integrations/sourcepoint/index.test.ts | 4 +- crates/trusted-server-core/src/consent/gpp.rs | 1 + .../src/integrations/registry.rs | 14 +- .../src/platform/test_support.rs | 19 - crates/trusted-server-core/src/settings.rs | 212 +------ .../plans/2026-04-16-prebid-user-id-module.md | 599 ------------------ ...26-04-15-sourcepoint-gpp-consent-design.md | 8 +- ...2026-04-16-prebid-user-id-module-design.md | 212 ------- 12 files changed, 49 insertions(+), 1451 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-16-prebid-user-id-module.md delete mode 100644 docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index b1a650b80..cc5690e0c 100644 --- a/crates/js/lib/build-all.mjs +++ b/crates/js/lib/build-all.mjs @@ -13,12 +13,6 @@ * 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'; @@ -113,132 +107,6 @@ 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/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index a9a561de9..53255d837 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -18,13 +18,6 @@ import 'prebid.js/modules/consentManagementGpp.js'; import 'prebid.js/modules/consentManagementUsp.js'; import 'prebid.js/modules/userId.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). diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts index e7024b625..1e3d7cea2 100644 --- a/crates/js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -14,6 +14,8 @@ interface SourcepointConsentPayload { } function findSourcepointConsent(): SourcepointConsentPayload | null { + // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. + // We intentionally take the first valid match and mirror that origin-scoped payload. for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; @@ -33,13 +35,13 @@ function findSourcepointConsent(): SourcepointConsentPayload | null { 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}`; + document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax`; } function clearCookie(name: string): void { + // Trusted Server is the only intended writer for these mirrored cookies, so + // clearing the origin-scoped cookie is sufficient for this integration. document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; } diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 60df059cd..470348613 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -1,6 +1,3 @@ -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 @@ -56,13 +53,8 @@ vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); vi.mock('prebid.js/modules/userId.js', () => ({})); -// 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) +// Mock the build-generated adapter imports (no-op in tests) vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); -vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({})); import { collectBidders, @@ -979,277 +971,3 @@ 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 index 872585c32..f276f2414 100644 --- a/crates/js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -6,7 +6,7 @@ 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=/`; + if (name) document.cookie = `${name}=; path=/; Max-Age=0`; }); } @@ -26,7 +26,7 @@ describe('integrations/sourcepoint', () => { localStorage.clear(); }); - it('mirrors __gpp and __gpp_sid from _sp_user_consent_* localStorage', () => { + it('mirrors __gpp and __gpp_sid from _sp_user_consent_* localStorage as session cookies', () => { const payload = { gppData: { gppString: 'DBABLA~BVQqAAAAAgA.QA', diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index cb704b029..bd8c12f24 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -152,6 +152,7 @@ fn us_sale_opt_out_from_section(section: &iab_gpp::sections::Section) -> Option< use iab_gpp::sections::us_common::OptOut; use iab_gpp::sections::Section; + // Keep this match in sync with new US-state variants added by `iab_gpp`. let sale_opt_out = match section { Section::UsNat(s) => match &s.core { iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out, diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index b91425b46..139a1c1bc 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -795,14 +795,14 @@ impl IntegrationRegistry { /// Return JS module IDs that should be included in the tsjs bundle. /// - /// Always includes "creative" (JS-only, no Rust-side registration). + /// Always includes JS-only modules with no Rust-side registration. /// Excludes integrations that have no JS module (e.g., "nextjs"). #[must_use] pub fn js_module_ids(&self) -> Vec<&'static str> { // Rust-only integrations with no corresponding JS module const JS_EXCLUDED: &[&str] = &["nextjs", "aps", "adserver_mock"]; // JS-only modules always included (no Rust-side registration) - const JS_ALWAYS: &[&str] = &["creative"]; + const JS_ALWAYS: &[&str] = &["creative", "sourcepoint"]; let mut ids: Vec<&'static str> = JS_ALWAYS.to_vec(); @@ -1416,7 +1416,7 @@ mod tests { } #[test] - fn js_module_ids_immediate_excludes_prebid() { + fn js_module_ids_immediate_excludes_prebid_and_includes_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1444,6 +1444,14 @@ mod tests { all.contains(&"prebid"), "should include prebid in full list" ); + assert!( + immediate.contains(&"creative"), + "should include creative in immediate IDs" + ); + assert!( + immediate.contains(&"sourcepoint"), + "should include sourcepoint in immediate IDs" + ); assert!( !immediate.contains(&"prebid"), "should not include prebid in immediate IDs" diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 818819f52..55a095a90 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -214,8 +214,6 @@ pub(crate) struct StubHttpClient { calls: Mutex>, // (status_code, body_bytes) — kept Send by avoiding Body::Stream responses: Mutex)>>, - // Headers captured per send call, stored as (name, value) string pairs. - request_headers: Mutex>>, } impl StubHttpClient { @@ -223,7 +221,6 @@ impl StubHttpClient { Self { calls: Mutex::new(Vec::new()), responses: Mutex::new(VecDeque::new()), - request_headers: Mutex::new(Vec::new()), } } @@ -253,22 +250,6 @@ impl PlatformHttpClient for StubHttpClient { .expect("should lock calls") .push(request.backend_name.clone()); - let headers: Vec<(String, String)> = request - .request - .headers() - .iter() - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|v| (name.as_str().to_string(), v.to_string())) - }) - .collect(); - self.request_headers - .lock() - .expect("should lock request_headers") - .push(headers); - let (status, body_bytes) = self .responses .lock() diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2334f6484..095c8afa7 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -98,27 +98,6 @@ pub trait IntegrationConfig: DeserializeOwned + Validate { fn is_enabled(&self) -> bool; } -fn normalize_env_value(value: JsonValue) -> JsonValue { - match value { - JsonValue::Object(map) => JsonValue::Object( - map.into_iter() - .map(|(key, val)| (key, normalize_env_value(val))) - .collect(), - ), - JsonValue::Array(items) => { - JsonValue::Array(items.into_iter().map(normalize_env_value).collect()) - } - JsonValue::String(raw) => { - if let Ok(parsed) = serde_json::from_str::(&raw) { - parsed - } else { - JsonValue::String(raw) - } - } - other => other, - } -} - impl IntegrationSettings { /// Inserts a configuration value for an integration. /// @@ -142,13 +121,34 @@ impl IntegrationSettings { Ok(()) } + fn normalize_env_value(value: JsonValue) -> JsonValue { + match value { + JsonValue::Object(map) => JsonValue::Object( + map.into_iter() + .map(|(key, val)| (key, Self::normalize_env_value(val))) + .collect(), + ), + JsonValue::Array(items) => { + JsonValue::Array(items.into_iter().map(Self::normalize_env_value).collect()) + } + JsonValue::String(raw) => { + if let Ok(parsed) = serde_json::from_str::(&raw) { + parsed + } else { + JsonValue::String(raw) + } + } + other => other, + } + } + /// Normalizes all entries in place, converting JSON-encoded strings from /// environment variables into their proper typed representations. /// Called eagerly after deserialization so that TOML serialization in /// build.rs preserves correct types. pub fn normalize(&mut self) { for value in self.entries.values_mut() { - *value = normalize_env_value(value.clone()); + *value = Self::normalize_env_value(value.clone()); } } @@ -989,9 +989,7 @@ where match v { JsonValue::Array(arr) => arr .into_iter() - .map(|item| { - serde_json::from_value(normalize_env_value(item)).map_err(serde::de::Error::custom) - }) + .map(|item| serde_json::from_value(item).map_err(serde::de::Error::custom)) .collect(), JsonValue::Object(map) => { let mut items: Vec<(usize, T)> = Vec::with_capacity(map.len()); @@ -999,8 +997,7 @@ where let idx = k.parse::().map_err(|_| { serde::de::Error::custom(format!("Invalid index '{}' in map for Vec field", k)) })?; - let parsed: T = serde_json::from_value(normalize_env_value(val)) - .map_err(serde::de::Error::custom)?; + let parsed: T = serde_json::from_value(val).map_err(serde::de::Error::custom)?; items.push((idx, parsed)); } items.sort_by_key(|(idx, _)| *idx); @@ -1702,167 +1699,6 @@ mod tests { ); } - #[test] - fn test_ec_partners_override_with_indexed_env() { - let toml_str = crate_test_settings_str(); - - let origin_key = format!( - "{}{}PUBLISHER{}ORIGIN_URL", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner0_id_key = format!( - "{}{}EC{}PARTNERS{}0{}ID", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner0_name_key = format!( - "{}{}EC{}PARTNERS{}0{}NAME", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner0_source_domain_key = format!( - "{}{}EC{}PARTNERS{}0{}SOURCE_DOMAIN", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner0_openrtb_atype_key = format!( - "{}{}EC{}PARTNERS{}0{}OPENRTB_ATYPE", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner0_bidstream_enabled_key = format!( - "{}{}EC{}PARTNERS{}0{}BIDSTREAM_ENABLED", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner0_api_token_key = format!( - "{}{}EC{}PARTNERS{}0{}API_TOKEN", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner1_id_key = format!( - "{}{}EC{}PARTNERS{}1{}ID", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner1_name_key = format!( - "{}{}EC{}PARTNERS{}1{}NAME", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner1_source_domain_key = format!( - "{}{}EC{}PARTNERS{}1{}SOURCE_DOMAIN", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner1_openrtb_atype_key = format!( - "{}{}EC{}PARTNERS{}1{}OPENRTB_ATYPE", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner1_bidstream_enabled_key = format!( - "{}{}EC{}PARTNERS{}1{}BIDSTREAM_ENABLED", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let partner1_api_token_key = format!( - "{}{}EC{}PARTNERS{}1{}API_TOKEN", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - - temp_env::with_vars( - [ - (origin_key, Some("https://origin.test-publisher.com")), - (partner0_id_key, Some("criteo")), - (partner0_name_key, Some("Criteo")), - (partner0_source_domain_key, Some("criteo.com")), - (partner0_openrtb_atype_key, Some("1")), - (partner0_bidstream_enabled_key, Some("true")), - (partner0_api_token_key, Some("criteo-test-token")), - (partner1_id_key, Some("uidapi")), - (partner1_name_key, Some("UID2")), - (partner1_source_domain_key, Some("uidapi.com")), - (partner1_openrtb_atype_key, Some("3")), - (partner1_bidstream_enabled_key, Some("true")), - (partner1_api_token_key, Some("uidapi-test-token")), - ], - || { - let settings = Settings::from_toml_and_env(&toml_str) - .expect("should parse EC partners from indexed env override"); - - assert_eq!( - settings.ec.partners.len(), - 2, - "should override partner list" - ); - assert_eq!(settings.ec.partners[0].id, "criteo"); - assert_eq!(settings.ec.partners[0].name, "Criteo"); - assert_eq!(settings.ec.partners[0].source_domain, "criteo.com"); - assert_eq!(settings.ec.partners[0].openrtb_atype, 1); - assert!( - settings.ec.partners[0].bidstream_enabled, - "should enable bidstream for partner 0" - ); - assert_eq!( - settings.ec.partners[0].api_token.expose(), - "criteo-test-token" - ); - - assert_eq!(settings.ec.partners[1].id, "uidapi"); - assert_eq!(settings.ec.partners[1].name, "UID2"); - assert_eq!(settings.ec.partners[1].source_domain, "uidapi.com"); - assert_eq!(settings.ec.partners[1].openrtb_atype, 3); - assert!( - settings.ec.partners[1].bidstream_enabled, - "should enable bidstream for partner 1" - ); - assert_eq!( - settings.ec.partners[1].api_token.expose(), - "uidapi-test-token" - ); - }, - ); - } - #[test] fn test_settings_extra_fields() { let toml_str = crate_test_settings_str() + "\nhello = 1"; diff --git a/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md b/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md deleted file mode 100644 index cbf3acaf2..000000000 --- a/docs/superpowers/plans/2026-04-16-prebid-user-id-module.md +++ /dev/null @@ -1,599 +0,0 @@ -# Prebid User ID Module — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bundle Prebid's User ID core module and a broad set of ID submodules so publisher-side `pbjs.setConfig({ userSync: { userIds: [...] } })` calls activate real ID resolution and the existing `syncPrebidEidsCookie()` helper begins writing `ts-eids`. - -**Architecture:** JS-only change. Add 15 static imports (1 core + 14 submodules) to `crates/js/lib/src/integrations/prebid/index.ts`. No Rust changes, no TOML changes, no new runtime logic. Existing `bidsBackHandler` shim and cookie-sync path already handle the rest; they were silent only because `pbjs.getUserIdsAsEids` did not exist. - -> **In-flight correction (2026-04-16):** `pubCommonIdSystem.js` was originally -> included in the plan as a legacy/compatibility submodule. It does not exist -> in Prebid 10.26.0 (consolidated into `sharedIdSystem`) and has been removed. -> All downstream counts reflect 14 submodules instead of 15. - -**Tech Stack:** TypeScript, Vitest, esbuild (via `build-all.mjs`), Prebid.js 9.x (via npm) - -**Spec:** `docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md` - ---- - -## File Map - -| File | Action | Responsibility | -| ------------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/js/lib/src/integrations/prebid/index.ts` | Modify | Add 15 User ID Module imports alongside existing `consentManagement*` imports | -| `crates/js/lib/test/integrations/prebid/index.test.ts` | Modify | Add `vi.mock` stubs for the 15 new modules, add `mockPbjs.getUserIdsAsEids`, add new tests for cookie-sync behavior and import regression guard | - -No other files change. `build.rs` picks up the rebuilt `dist/tsjs-prebid.js` automatically via `include_str!`. - ---- - -## Task 1: Document existing `syncPrebidEidsCookie` behavior with tests - -The sync helper already exists but has no test coverage. Before changing anything, lock in its current contract so we can refactor or extend later without regressions. These tests exercise the `bidsBackHandler` shim path end-to-end using the existing mocks. - -**Files:** - -- Modify: `crates/js/lib/test/integrations/prebid/index.test.ts` - -- [ ] **Step 1: Add `getUserIdsAsEids` to the hoisted pbjs mock** - -In `crates/js/lib/test/integrations/prebid/index.test.ts` inside the existing `vi.hoisted(() => { ... })` block, add a new mock function and include it on `mockPbjs`. Replace the current block with: - -```ts -const { - mockSetConfig, - mockProcessQueue, - mockRequestBids, - mockRegisterBidAdapter, - mockGetUserIdsAsEids, - mockPbjs, - mockGetBidAdapter, - mockAdapterManager, -} = vi.hoisted(() => { - const mockSetConfig = vi.fn() - const mockProcessQueue = vi.fn() - 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 = { - getBidAdapter: mockGetBidAdapter, - } - return { - mockSetConfig, - mockProcessQueue, - mockRequestBids, - mockRegisterBidAdapter, - mockGetUserIdsAsEids, - mockPbjs, - mockGetBidAdapter, - mockAdapterManager, - } -}) -``` - -- [ ] **Step 2: Write the failing test — empty EID array writes no cookie** - -Append this new `describe` block at the end of `crates/js/lib/test/integrations/prebid/index.test.ts`: - -```ts -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() - }) -}) -``` - -- [ ] **Step 3: Run the test — expect PASS (documents existing behavior)** - -Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "writes no cookie when getUserIdsAsEids returns empty array"` - -Expected: PASS. The shim already calls `syncPrebidEidsCookie` which early-returns on empty input. - -- [ ] **Step 4: Add test — writes base64 cookie for a normal payload** - -Append inside the same `describe` block: - -```ts -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 }, - ]) -}) -``` - -- [ ] **Step 5: Run and confirm PASS** - -Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "writes ts-eids cookie with base64"` - -Expected: PASS. - -- [ ] **Step 6: Add test — defaults atype to 3 when missing** - -Append: - -```ts -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 }]) -}) -``` - -- [ ] **Step 7: Add test — skips entries without an id or source** - -Append: - -```ts -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 }]) -}) -``` - -- [ ] **Step 8: Add test — takes first uid when multiple are present** - -Append: - -```ts -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 }]) -}) -``` - -- [ ] **Step 9: Add test — trims tail when payload exceeds 3072 bytes** - -Append: - -```ts -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') -}) -``` - -- [ ] **Step 10: Add test — writes no cookie when a single entry alone exceeds the cap** - -Append: - -```ts -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() -}) -``` - -- [ ] **Step 11: Add test — does not throw when getUserIdsAsEids is undefined** - -This mirrors the pre-fix production state and guards against regressions in the defensive check at `index.ts:375`. Append: - -```ts -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 -}) -``` - -- [ ] **Step 12: Add test — calls the original bidsBackHandler when one was supplied** - -Append: - -```ts -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) -}) -``` - -- [ ] **Step 13: Run the full new block and confirm all pass** - -Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "syncPrebidEidsCookie"` - -Expected: all 9 new tests PASS. If any fail, investigate before proceeding — the rest of the plan assumes this behavior is locked in. - -- [ ] **Step 14: Run the entire prebid test file to confirm no regressions** - -Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts` - -Expected: all tests PASS (new + existing). - -- [ ] **Step 15: Commit** - -```bash -git add crates/js/lib/test/integrations/prebid/index.test.ts -git commit -m "Add Vitest coverage for Prebid ts-eids cookie sync" -``` - ---- - -## Task 2: Add Prebid User ID core and submodule imports - -This is the substantive change. Add `vi.mock` stubs for the new modules first (so tests don't blow up when the imports are added), then add the imports. - -**Files:** - -- Modify: `crates/js/lib/test/integrations/prebid/index.test.ts:44-47` -- Modify: `crates/js/lib/src/integrations/prebid/index.ts:16-18` - -- [ ] **Step 1: Add `vi.mock` stubs in the test file for all 15 new modules** - -In `crates/js/lib/test/integrations/prebid/index.test.ts`, locate the existing block (around line 44-47): - -```ts -// Side-effect imports are no-ops in tests -vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})) -vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})) -vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})) -``` - -Replace it with (consent management mocks stay; add 15 new ones): - -```ts -// Side-effect imports are no-ops in tests -vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})) -vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})) -vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})) - -// User ID Module core + submodules — no-op mocks so jsdom does not try to -// execute the real Prebid code paths. -vi.mock('prebid.js/modules/userId.js', () => ({})) -vi.mock('prebid.js/modules/sharedIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/criteoIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/33acrossIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/pubProvidedIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/quantcastIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/id5IdSystem.js', () => ({})) -vi.mock('prebid.js/modules/identityLinkIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/liveIntentIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/uid2IdSystem.js', () => ({})) -vi.mock('prebid.js/modules/euidIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/intentIqIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/lotamePanoramaIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/connectIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/merkleIdSystem.js', () => ({})) -vi.mock('prebid.js/modules/pubCommonIdSystem.js', () => ({})) -``` - -- [ ] **Step 2: Write the failing regression-guard test** - -Append this `describe` block to `crates/js/lib/test/integrations/prebid/index.test.ts`: - -```ts -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { resolve } from 'node:path' - -describe('prebid/index.ts User ID Module imports (regression guard)', () => { - const REQUIRED_IMPORTS = [ - 'prebid.js/modules/userId.js', - 'prebid.js/modules/sharedIdSystem.js', - 'prebid.js/modules/criteoIdSystem.js', - 'prebid.js/modules/33acrossIdSystem.js', - 'prebid.js/modules/pubProvidedIdSystem.js', - 'prebid.js/modules/quantcastIdSystem.js', - 'prebid.js/modules/id5IdSystem.js', - 'prebid.js/modules/identityLinkIdSystem.js', - 'prebid.js/modules/liveIntentIdSystem.js', - 'prebid.js/modules/uid2IdSystem.js', - 'prebid.js/modules/euidIdSystem.js', - 'prebid.js/modules/intentIqIdSystem.js', - 'prebid.js/modules/lotamePanoramaIdSystem.js', - 'prebid.js/modules/connectIdSystem.js', - 'prebid.js/modules/merkleIdSystem.js', - 'prebid.js/modules/pubCommonIdSystem.js', - ] - - // Source-text check: these mocks make the runtime pbjs mock a no-op for the - // User ID Module, so there is no way to assert `typeof getUserIdsAsEids === - // 'function'` at import time from within Vitest. Reading the source file - // directly is the most reliable way to catch accidental removal of an - // import, which is the exact regression that motivated this work. - // The package is ESM (`"type": "module"`), so `__dirname` is not defined — - // resolve relative to this file via `import.meta.url`. - const THIS_DIR = fileURLToPath(new URL('.', import.meta.url)) - const SOURCE_PATH = resolve( - THIS_DIR, - '../../../src/integrations/prebid/index.ts' - ) - const source = readFileSync(SOURCE_PATH, 'utf8') - - for (const module of REQUIRED_IMPORTS) { - it(`statically imports ${module}`, () => { - const pattern = new RegExp( - `import\\s+['"]${module.replace(/\./g, '\\.')}['"]` - ) - expect(source).toMatch(pattern) - }) - } -}) -``` - -- [ ] **Step 3: Run the new block — expect 15 failures** - -Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "regression guard"` - -Expected: FAIL — 15 failing assertions, one per expected import. This confirms the regression guard actually reads the source. - -- [ ] **Step 4: Add the 15 imports to `index.ts`** - -In `crates/js/lib/src/integrations/prebid/index.ts`, locate lines 16-18: - -```ts -import 'prebid.js/modules/consentManagementTcf.js' -import 'prebid.js/modules/consentManagementGpp.js' -import 'prebid.js/modules/consentManagementUsp.js' -``` - -Insert the User ID imports immediately after them, before the existing `// Client-side bid adapters` comment block. The resulting section must read: - -```ts -import 'prebid.js/modules/consentManagementTcf.js' -import 'prebid.js/modules/consentManagementGpp.js' -import 'prebid.js/modules/consentManagementUsp.js' - -// Prebid User ID Module — core + submodules. The core module exposes -// `pbjs.getUserIdsAsEids`; submodules self-register at import time and -// activate when the publisher's origin-side `pbjs.setConfig({ userSync: -// { userIds: [...] } })` call runs during `processQueue()`. -import 'prebid.js/modules/userId.js' - -// Zero-config / auto-populating submodules (resolve without publisher params). -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' - -// Param-based submodules — inert until publisher setConfig supplies params. -import 'prebid.js/modules/id5IdSystem.js' -import 'prebid.js/modules/identityLinkIdSystem.js' -import 'prebid.js/modules/liveIntentIdSystem.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' - -// Legacy / deprecated but still present in some publisher configs. -import 'prebid.js/modules/pubCommonIdSystem.js' -``` - -- [ ] **Step 5: Run the regression-guard block — expect PASS** - -Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts -t "regression guard"` - -Expected: all 15 tests PASS. - -- [ ] **Step 6: Run the full prebid test file — expect no regressions** - -Run: `cd crates/js/lib && npx vitest run test/integrations/prebid/index.test.ts` - -Expected: all tests PASS (Task 1 tests + regression guards + all pre-existing tests). - -- [ ] **Step 7: Run the entire JS test suite** - -Run: `cd crates/js/lib && npx vitest run` - -Expected: all PASS. If any unrelated test fails because it imports `./index.ts` transitively and the real Prebid modules are not mocked there, add matching `vi.mock` stubs at the top of that test file (copy the same 15 lines). Common suspects: any test under `crates/js/lib/test/integrations/prebid/` you may have added, or tests that import core modules which in turn import the prebid integration. At the time of writing, no other test file imports the prebid integration. - -- [ ] **Step 8: Build the JS bundles** - -Run: `cd crates/js/lib && node build-all.mjs` - -Expected: build succeeds. `dist/tsjs-prebid.js` gets substantially larger (est. 100-150kb gzipped increase). No esbuild errors about missing modules — if there are, the module path in the new imports is wrong (check `crates/js/lib/node_modules/prebid.js/modules/` for the exact filename — note `33acrossIdSystem.js` really does start with a digit and is correct). - -- [ ] **Step 9: Format the JS** - -Run: `cd crates/js/lib && npm run format` - -Expected: prettier rewrites any formatting drift in the files you touched. No errors. - -- [ ] **Step 10: Verify the Rust build picks up the rebuilt bundle** - -Run: `cargo check --package trusted-server-core` - -Expected: PASS. `build.rs` re-runs because `dist/tsjs-prebid.js` changed; `include_str!` pulls in the new content. - -- [ ] **Step 11: Run full Rust test suite to confirm no downstream breakage** - -Run: `cargo test --workspace` - -Expected: PASS. The Rust side does not inspect bundle contents, only concatenates and hashes them, so tests should be unaffected. - -- [ ] **Step 12: Commit** - -```bash -git add crates/js/lib/src/integrations/prebid/index.ts crates/js/lib/test/integrations/prebid/index.test.ts -git commit -m "Bundle Prebid User ID core and submodules in Prebid integration" -``` - ---- - -## Task 3: Final verification - -- [ ] **Step 1: Full CI-equivalent check** - -Run the same sequence CI runs: - -```bash -cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace -cd crates/js/lib && npx vitest run && cd ../../.. -cd crates/js/lib && npm run format && cd ../../.. -``` - -Expected: everything PASS / clean. - -- [ ] **Step 2: Manual verification note** - -Manual browser verification (cannot be automated here; run against a dev publisher environment that has origin-side `pbjs.setConfig({ userSync: { userIds: [...] } })`): - -1. Load a publisher page. In DevTools console: `typeof pbjs.getUserIdsAsEids` should return `'function'`. -2. `pbjs.getUserIdsAsEids()` should return a non-empty array. -3. After the first ad-slot auction completes: `document.cookie.match(/ts-eids=/)` should match. -4. Decode the cookie: `JSON.parse(atob(document.cookie.match(/ts-eids=([^;]+)/)[1]))` should produce a `[{source, id, atype}]` array matching the raw EIDs. -5. Network tab: the second `/auction` request should carry `Cookie: ts-eids=...`. - -These are documented in the spec; they are not blockers for the PR, but they should be run before closing out the work. - -- [ ] **Step 3: No follow-up commits required** - -The work is complete when Tasks 1 and 2 are committed. Do not create a third "chore" commit unless format/clippy asks for one. - ---- - -## What this plan intentionally does NOT do - -- Does **not** add a build-time env-var toggle (`TSJS_PREBID_USER_IDS`) to mirror `TSJS_PREBID_ADAPTERS`. Deferred per spec. -- Does **not** add `window.__tsjs_prebid.userIds` server-side injection. Deferred per spec. -- Does **not** change `[[ec.partners]]` or `crates/trusted-server-core/src/ec/prebid_eids.rs`. Backend already handles received cookies correctly. -- Does **not** add a bundle-size regression gate. Noted as a known cost in the spec. -- Does **not** add tests for individual ID submodule resolution behavior. That is Prebid's own test surface, not ours. diff --git a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md index ed72f3f55..2455e12a4 100644 --- a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md +++ b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md @@ -40,7 +40,7 @@ No Rust-side `IntegrationRegistration` (same pattern as `creative`). **On page load:** 1. Scan `localStorage` keys matching `_sp_user_consent_*`. -2. Take the first match, parse the JSON value. +2. Take the first valid match, parse the JSON value. 3. Extract `gppData.gppString` and `gppData.applicableSections` from the payload. 4. Write first-party cookies: - `__gpp=` (path `/`, `SameSite=Lax`) @@ -48,8 +48,10 @@ No Rust-side `IntegrationRegistration` (same pattern as `creative`). 5. Log what was written for debugging. Cookies are session-scoped (no `max-age` / `expires`) since the source of truth -stays in `localStorage` and we re-mirror on each page load. The integration runs -once — no polling or event listeners. +stays in `localStorage` and we re-mirror on each page load. This design assumes +a single active Sourcepoint property per page; if multiple `_sp_user_consent_*` +entries coexist, the first valid one wins. The integration runs once — no +polling or event listeners. ### 2. Server-side: GPP US section decoding diff --git a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md b/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md deleted file mode 100644 index 2ef80a3eb..000000000 --- a/docs/superpowers/specs/2026-04-16-prebid-user-id-module-design.md +++ /dev/null @@ -1,212 +0,0 @@ -# Prebid User ID Module support - -**Date:** 2026-04-16 -**Status:** Design -**Scope:** JS bundle (`crates/js/lib/src/integrations/prebid/index.ts`) - -## Problem - -The Trusted Server Prebid integration strips each publisher's origin `prebid.js` -and replaces it with a server-bundled build. That bundle imports the consent -management modules but does **not** import Prebid's User ID core module or any -ID submodules. As a result `pbjs.getUserIdsAsEids` is `undefined` at runtime, -the `syncPrebidEidsCookie()` helper early-returns, and the `ts-eids` cookie is -never written — even when the publisher's origin-side code has a fully -configured `userSync.userIds` list. - -Downstream, `crates/trusted-server-core/src/ec/prebid_eids.rs` never receives a -cookie to ingest, so matched partner UIDs never land in the KV identity graph. - -## Goal - -Bundle Prebid's User ID core module and a broad, widely-deployed set of ID -submodules so publishers' existing `pbjs.setConfig({ userSync: { userIds: ... } })` -calls activate real ID resolution. After first auction completes, `ts-eids` -cookie is written and the backend ingestion path (already implemented) takes -over. - -## Non-goals - -- No Rust changes. No new `trusted-server.toml` fields. -- No runtime config injection from the server (`window.__tsjs_prebid.userIds`) - — deferred to a follow-up. -- No build-time env-var toggle for the bundled set (e.g. `TSJS_PREBID_USER_IDS` - mirroring `TSJS_PREBID_ADAPTERS`) — deferred to a follow-up. -- No automatic alignment between bundled ID submodules and configured - `[[ec.partners]]` — operators must keep those in sync themselves. - -## Design - -### Bundled modules - -Exactly one file changes: `crates/js/lib/src/integrations/prebid/index.ts`. -Add static imports near the existing `consentManagement*.js` imports. - -**Core (required):** - -- `prebid.js/modules/userId.js` - -**Zero-config / auto-populating submodules** (resolve without publisher params): - -- `prebid.js/modules/sharedIdSystem.js` -- `prebid.js/modules/criteoIdSystem.js` -- `prebid.js/modules/33acrossIdSystem.js` -- `prebid.js/modules/pubProvidedIdSystem.js` -- `prebid.js/modules/quantcastIdSystem.js` - -**Param-based submodules** (inert until the publisher's `setConfig` supplies -the relevant params): - -- `prebid.js/modules/id5IdSystem.js` -- `prebid.js/modules/identityLinkIdSystem.js` -- `prebid.js/modules/uid2IdSystem.js` -- `prebid.js/modules/euidIdSystem.js` -- `prebid.js/modules/intentIqIdSystem.js` -- `prebid.js/modules/lotamePanoramaIdSystem.js` -- `prebid.js/modules/connectIdSystem.js` -- `prebid.js/modules/merkleIdSystem.js` - -Total: 1 core + 13 submodules = 14 new imports. - -> **Notes (2026-04-16, during implementation):** -> -> - `pubCommonIdSystem.js`, originally planned as a legacy/compatibility -> submodule, was removed from Prebid.js in 10.x (consolidated into -> `sharedIdSystem`). Not importable from our pinned Prebid 10.26.0; dropped. -> - `liveIntentIdSystem.js` was dropped from the day-1 bundle. Its upstream -> module 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()` call in the bundle, which throws `ReferenceError: require -> is not defined` at browser runtime. Re-enabling requires an esbuild -> resolver plugin (or Prebid's own build pipeline). Tracked as a follow-up. - -No changes to `installPrebidNpm`, no changes to the `bidsBackHandler` shim, no -changes to `syncPrebidEidsCookie`. The existing cookie-writing path is already -correct — it was only silent because `pbjs.getUserIdsAsEids` did not exist. - -### Runtime flow - -No new runtime logic. The sequence below is what will light up once the -submodules are present: - -1. Rust `IntegrationHeadInjector` emits the `window.pbjs` / `window.pbjs.que` - / `window.__tsjs_prebid` bootstrap before any publisher-origin script runs. -2. Publisher origin code queues its existing config: - `pbjs.que.push(() => pbjs.setConfig({ userSync: { userIds: [...] } }))`. -3. Our bundle loads. `installPrebidNpm()` registers the `trustedServer` - adapter, shims `requestBids` (already appends a chained `bidsBackHandler` - calling `syncPrebidEidsCookie`), then calls `pbjs.processQueue()` — the - publisher's queued `setConfig` runs at this point and activates the - configured submodules (each self-registered at import time). -4. User ID Module resolves IDs per its own rules (TCF/GPP/USP-gated, async). -5. First `requestBids` fires. Auction completes. Chained `bidsBackHandler` - calls `syncPrebidEidsCookie()`. -6. `syncPrebidEidsCookie` calls `pbjs.getUserIdsAsEids()` (now a real - function), flattens `[{source, id, atype}]`, base64-encodes JSON, writes - `document.cookie = "ts-eids=..."`. -7. Subsequent `/auction` requests carry `Cookie: ts-eids=...`. -8. Backend (`crates/trusted-server-core/src/ec/prebid_eids.rs`) parses the - cookie, matches `source` against `[[ec.partners]]`, syncs partner UIDs to - KV. - -The first `/auction` request after a cold page load still will not carry -`ts-eids`, because the cookie is written in the post-auction handler. This -matches preexisting behavior. - -### Error handling - -All failure modes are already covered by existing code. No new error paths. - -- **Publisher has no `userSync.userIds` configured** → - `pbjs.getUserIdsAsEids()` returns `[]` → early-return at `index.ts:380-382`. - No cookie written. Silent. Correct. -- **Submodule fails to resolve** (no consent, no third-party ID, network - error) → handled inside Prebid; `getUserIdsAsEids()` returns only the - resolved subset. Cookie reflects what resolved. -- **Cookie payload exceeds 3072 bytes** → existing trim-and-retry loop at - `index.ts:404-411` drops entries from the tail until it fits. If a single - entry alone exceeds the cap, no cookie is written. -- **Unexpected exception in sync path** → caught by the existing `try/catch` - at `index.ts:417-419`, logged via `log.warn`, does not break the auction. -- **Module import failure at build time** → esbuild fails the build. This - catches missing or renamed Prebid modules before they ship. - -### Known caveats - -- **Backend pairing** — an EID whose `source` has no matching `[[ec.partners]]` - entry is dropped at the backend (with a debug log). Bundling - `id5IdSystem.js` is inert for EC identity-graph purposes unless the - operator also adds an `[[ec.partners]]` entry with - `source_domain = "id5-sync.com"`. Operators must keep the two lists in - sync. Not a code change here; documented as an operator concern. -- **Bundle size** — adding 15 modules increases the shipped `tsjs-prebid.js` - by an estimated ~100-150kb gzipped. Not gated on a build-time toggle in - this change. - -## Testing - -### Automated (Vitest) - -Add tests under `crates/js/lib/src/integrations/prebid/`: - -- **Import smoke test** — import `./index.ts` and assert - `typeof pbjs.getUserIdsAsEids === 'function'`. Guards against the exact - regression that motivated this work. -- **`syncPrebidEidsCookie` unit tests** (new or expanded) — mock - `pbjs.getUserIdsAsEids` to return a fixed `[{source, uids: [{id, atype}]}]` - array and assert the cookie is written with base64-encoded - `[{source, id, atype}]`. Cover: - - empty array → no cookie written - - normal payload → cookie written with expected value - - oversize payload → trimmed to fit; partial entries persisted - - single oversize entry → no cookie written - -### Manual (after deploy to a dev publisher) - -- DevTools console: `typeof pbjs.getUserIdsAsEids === 'function'` returns - `true`. -- `pbjs.getUserIdsAsEids()` returns a non-empty array for a publisher with - configured `userIds`. -- After the first auction: `document.cookie` contains `ts-eids=...`. Decoded - payload (base64 → JSON) matches the raw EIDs. -- Network tab: second `/auction` request carries `Cookie: ts-eids=...`. - -### Explicitly out of scope - -- Each individual ID submodule's resolution behavior — that is Prebid's - responsibility and covered by Prebid's own test suite. -- Backend ingestion of `ts-eids` — already covered by `prebid_eids.rs` - tests; no new backend code. -- Bundle-size regression gating — noted as a caveat, not enforced. - -## Rollout - -This is a bundle change only. No migration, no feature flag, no staged -rollout beyond normal deploy. - -On first deploy, publishers with active origin-side `userSync.userIds` -configuration will begin emitting `ts-eids` cookies after their first -auction. Publishers without `userSync.userIds` configured see no change. - -## Follow-ups - -1. ~~**Build-time configurability** — introduce `_user_ids.generated.ts` - driven by a `TSJS_PREBID_USER_IDS` env var, mirroring the existing - `TSJS_PREBID_ADAPTERS` / `_adapters.generated.ts` pattern. Allows - operators to slim the bundle per deployment.~~ **Implemented - 2026-04-16** — see `docs/guide/integrations/prebid.md` "Build-time - submodule selection" and the `generatePrebidUserIds()` function in - `crates/js/lib/build-all.mjs`. -2. **Server-injected `userSync.userIds`** — extend `trusted-server.toml` - with a `[[integrations.prebid.user_ids]]` array. Rust serializes into - `window.__tsjs_prebid.userIds`. JS applies via `pbjs.setConfig` before - `processQueue()`. Supports publishers who do not run their own Prebid - config on origin. -3. **Partner alignment tooling** — a startup-time check that warns when a - bundled ID submodule has no matching `[[ec.partners]]` entry, or vice - versa. -4. **Re-enable `liveIntentIdSystem.js`** — requires either an esbuild - resolver plugin that rewrites the dynamic `require('../libraries/ - liveIntentId/idSystem.js')` inside `loadModule()` to a static import, or - adopting Prebid's own gulp build pipeline for the vendored bundle. - Out-of-scope for the initial ship. From 4980e7794ec5782dc3d3fbde0aa1f4b5a52500d9 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 1 May 2026 09:45:20 -0500 Subject: [PATCH 19/22] Remove generated Prebid user ID shim --- .../prebid/_user_ids.generated.ts | 22 ------------------- .../js/lib/src/integrations/prebid/index.ts | 5 ----- .../src/platform/test_support.rs | 19 ++++++++++++++++ 3 files changed, 19 insertions(+), 27 deletions(-) delete mode 100644 crates/js/lib/src/integrations/prebid/_user_ids.generated.ts diff --git a/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts deleted file mode 100644 index 9eb586ddd..000000000 --- a/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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 53255d837..b8d6d73b6 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -18,11 +18,6 @@ import 'prebid.js/modules/consentManagementGpp.js'; import 'prebid.js/modules/consentManagementUsp.js'; 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). diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 55a095a90..818819f52 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -214,6 +214,8 @@ pub(crate) struct StubHttpClient { calls: Mutex>, // (status_code, body_bytes) — kept Send by avoiding Body::Stream responses: Mutex)>>, + // Headers captured per send call, stored as (name, value) string pairs. + request_headers: Mutex>>, } impl StubHttpClient { @@ -221,6 +223,7 @@ impl StubHttpClient { Self { calls: Mutex::new(Vec::new()), responses: Mutex::new(VecDeque::new()), + request_headers: Mutex::new(Vec::new()), } } @@ -250,6 +253,22 @@ impl PlatformHttpClient for StubHttpClient { .expect("should lock calls") .push(request.backend_name.clone()); + let headers: Vec<(String, String)> = request + .request + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|v| (name.as_str().to_string(), v.to_string())) + }) + .collect(); + self.request_headers + .lock() + .expect("should lock request_headers") + .push(headers); + let (status, body_bytes) = self .responses .lock() From 2b4a61090ba72f3df33e4c878e717aa5f784610b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 5 May 2026 07:48:17 -0500 Subject: [PATCH 20/22] Address Sourcepoint consent review feedback --- .../lib/src/integrations/sourcepoint/index.ts | 76 +++++++++-- .../integrations/sourcepoint/index.test.ts | 127 ++++++++++++------ crates/trusted-server-core/src/consent/gpp.rs | 2 +- crates/trusted-server-core/src/consent/mod.rs | 1 + .../src/integrations/registry.rs | 3 +- docs/guide/integrations/prebid.md | 39 +----- ...26-04-15-sourcepoint-gpp-consent-design.md | 37 +++-- 7 files changed, 192 insertions(+), 93 deletions(-) diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts index 1e3d7cea2..0e31b0869 100644 --- a/crates/js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -3,6 +3,9 @@ import { log } from '../../core/log'; const SP_CONSENT_PREFIX = '_sp_user_consent_'; const GPP_COOKIE_NAME = '__gpp'; const GPP_SID_COOKIE_NAME = '__gpp_sid'; +const GPP_SOURCE_COOKIE_NAME = '_ts_gpp_src'; +const GPP_SOURCE_SOURCEPOINT = 'sp'; +const INITIAL_RETRY_DELAY_MS = 500; interface SourcepointGppData { gppString: string; @@ -13,6 +16,8 @@ interface SourcepointConsentPayload { gppData?: SourcepointGppData; } +let initialized = false; + function findSourcepointConsent(): SourcepointConsentPayload | null { // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. // We intentionally take the first valid match and mirror that origin-scoped payload. @@ -35,16 +40,52 @@ function findSourcepointConsent(): SourcepointConsentPayload | null { return null; } +function readCookie(name: string): string | undefined { + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + return cookie?.slice(prefix.length); +} + +function hasSourcepointMarker(): boolean { + return readCookie(GPP_SOURCE_COOKIE_NAME) === GPP_SOURCE_SOURCEPOINT; +} + function writeCookie(name: string, value: string): void { document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax`; } function clearCookie(name: string): void { - // Trusted Server is the only intended writer for these mirrored cookies, so - // clearing the origin-scoped cookie is sufficient for this integration. document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; } +function clearSourcepointCookies(): void { + if (!hasSourcepointMarker()) { + return; + } + + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + clearCookie(GPP_SOURCE_COOKIE_NAME); +} + +function mirrorOnVisible(): void { + if (document.visibilityState === 'visible') { + mirrorSourcepointConsent(); + } +} + +function scheduleInitialRetry(): void { + const retry = (): void => { + mirrorSourcepointConsent(); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', retry, { once: true }); + } + + window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); +} + /** * Reads Sourcepoint consent from localStorage and mirrors it into * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. @@ -58,25 +99,24 @@ export function mirrorSourcepointConsent(): boolean { const payload = findSourcepointConsent(); if (!payload?.gppData) { - clearCookie(GPP_COOKIE_NAME); - clearCookie(GPP_SID_COOKIE_NAME); + clearSourcepointCookies(); 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); + clearSourcepointCookies(); log.debug('sourcepoint: gppString is empty'); return false; } + writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); writeCookie(GPP_COOKIE_NAME, gppString); if (Array.isArray(applicableSections) && applicableSections.length > 0) { writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); - } else { + } else if (hasSourcepointMarker()) { clearCookie(GPP_SID_COOKIE_NAME); } @@ -88,8 +128,24 @@ export function mirrorSourcepointConsent(): boolean { return true; } -if (typeof window !== 'undefined') { - mirrorSourcepointConsent(); +/** + * Initializes Sourcepoint consent mirroring and bounded refresh hooks. + */ +export function initializeSourcepointConsentMirror(): void { + if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + initialized = true; + + if (!mirrorSourcepointConsent()) { + scheduleInitialRetry(); + } + + // Sourcepoint persists consent changes to localStorage. Re-mirror when a + // user returns to the page so session cookies do not remain stale. + document.addEventListener('visibilitychange', mirrorOnVisible); + window.addEventListener('focus', mirrorSourcepointConsent); } -export default mirrorSourcepointConsent; +initializeSourcepointConsentMirror(); diff --git a/crates/js/lib/test/integrations/sourcepoint/index.test.ts b/crates/js/lib/test/integrations/sourcepoint/index.test.ts index f276f2414..3914c2ef9 100644 --- a/crates/js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -1,7 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; +const SOURCEPOINT_MARKER_COOKIE = '_ts_gpp_src'; + +function sourcepointPayload(gppString = 'DBABLA~BVQqAAAAAgA.QA', applicableSections = [7]) { + return { + gppData: { + gppString, + applicableSections, + }, + }; +} + describe('integrations/sourcepoint', () => { function clearAllCookies(): void { document.cookie.split(';').forEach((c) => { @@ -22,34 +33,27 @@ describe('integrations/sourcepoint', () => { }); afterEach(() => { + vi.useRealTimers(); clearAllCookies(); localStorage.clear(); }); it('mirrors __gpp and __gpp_sid from _sp_user_consent_* localStorage as session cookies', () => { - const payload = { - gppData: { - gppString: 'DBABLA~BVQqAAAAAgA.QA', - applicableSections: [7], - }, - }; - localStorage.setItem('_sp_user_consent_36026', JSON.stringify(payload)); + localStorage.setItem('_sp_user_consent_36026', JSON.stringify(sourcepointPayload())); const result = mirrorSourcepointConsent(); expect(result).toBe(true); expect(document.cookie).toContain('__gpp=DBABLA~BVQqAAAAAgA.QA'); expect(document.cookie).toContain('__gpp_sid=7'); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); }); it('handles multiple applicable sections', () => { - const payload = { - gppData: { - gppString: 'DBABLA~BVQqAAAAAgA.QA', - applicableSections: [7, 8], - }, - }; - localStorage.setItem('_sp_user_consent_99999', JSON.stringify(payload)); + localStorage.setItem( + '_sp_user_consent_99999', + JSON.stringify(sourcepointPayload('DBABLA~BVQqAAAAAgA.QA', [7, 8])) + ); mirrorSourcepointConsent(); @@ -66,9 +70,22 @@ describe('integrations/sourcepoint', () => { expect(document.cookie).not.toContain('__gpp_sid='); }); - it('clears stale mirrored cookies when no valid Sourcepoint payload exists', () => { + it('does not clear non-Sourcepoint GPP cookies when no valid Sourcepoint payload exists', () => { + document.cookie = '__gpp=other-cmp-gpp; path=/'; + document.cookie = '__gpp_sid=7,8; path=/'; + localStorage.setItem('unrelated_key', 'value'); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(getCookie('__gpp')).toBe('other-cmp-gpp'); + expect(getCookie('__gpp_sid')).toBe('7,8'); + }); + + it('clears stale Sourcepoint-owned mirrored cookies when no valid Sourcepoint payload exists', () => { document.cookie = '__gpp=stale-gpp; path=/'; document.cookie = '__gpp_sid=7,8; path=/'; + document.cookie = `${SOURCEPOINT_MARKER_COOKIE}=sp; path=/`; localStorage.setItem('unrelated_key', 'value'); const result = mirrorSourcepointConsent(); @@ -76,6 +93,7 @@ describe('integrations/sourcepoint', () => { expect(result).toBe(false); expect(getCookie('__gpp')).toBeUndefined(); expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBeUndefined(); }); it('returns false for malformed JSON in localStorage', () => { @@ -89,15 +107,7 @@ describe('integrations/sourcepoint', () => { 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], - }, - }) - ); + localStorage.setItem('_sp_user_consent_67890', JSON.stringify(sourcepointPayload())); const result = mirrorSourcepointConsent(); @@ -116,13 +126,7 @@ describe('integrations/sourcepoint', () => { }); it('returns false when gppString is empty', () => { - const payload = { - gppData: { - gppString: '', - applicableSections: [7], - }, - }; - localStorage.setItem('_sp_user_consent_12345', JSON.stringify(payload)); + localStorage.setItem('_sp_user_consent_12345', JSON.stringify(sourcepointPayload('', [7]))); const result = mirrorSourcepointConsent(); @@ -132,14 +136,10 @@ describe('integrations/sourcepoint', () => { it('clears stale __gpp_sid when the payload has no applicable sections', () => { document.cookie = '__gpp_sid=7,8; path=/'; + document.cookie = `${SOURCEPOINT_MARKER_COOKIE}=sp; path=/`; localStorage.setItem( '_sp_user_consent_12345', - JSON.stringify({ - gppData: { - gppString: 'DBABLA~BVQqAAAAAgA.QA', - applicableSections: [], - }, - }) + JSON.stringify(sourcepointPayload('DBABLA~BVQqAAAAAgA.QA', [])) ); const result = mirrorSourcepointConsent(); @@ -147,5 +147,56 @@ describe('integrations/sourcepoint', () => { expect(result).toBe(true); expect(getCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); + }); + + it('refreshes mirrored cookies when the window regains focus', () => { + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('initial-gpp', [7])) + ); + + mirrorSourcepointConsent(); + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('updated-gpp', [8])) + ); + window.dispatchEvent(new Event('focus')); + + expect(getCookie('__gpp')).toBe('updated-gpp'); + expect(getCookie('__gpp_sid')).toBe('8'); + }); + + it('clears Sourcepoint-owned cookies when consent is retracted before focus', () => { + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('initial-gpp', [7])) + ); + + mirrorSourcepointConsent(); + localStorage.removeItem('_sp_user_consent_12345'); + window.dispatchEvent(new Event('focus')); + + expect(getCookie('__gpp')).toBeUndefined(); + expect(getCookie('__gpp_sid')).toBeUndefined(); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBeUndefined(); + }); + + it('retries once after module initialization when Sourcepoint data appears shortly after load', async () => { + vi.useFakeTimers(); + vi.resetModules(); + localStorage.clear(); + clearAllCookies(); + + await import('../../../src/integrations/sourcepoint'); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('retry-gpp', [7])) + ); + vi.advanceTimersByTime(500); + + expect(getCookie('__gpp')).toBe('retry-gpp'); + expect(getCookie('__gpp_sid')).toBe('7'); }); }); diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index bd8c12f24..a6a9766d6 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -71,10 +71,10 @@ pub fn decode_gpp_string(gpp_string: &str) -> Result bool { } // When a CMP uses TCF in the US (e.g. Didomi), respect the // TCF Purpose 1 decision — this is an explicit opt-in signal. + // The Sourcepoint GPP design documents this precedence decision. if let Some(tcf) = effective_tcf(ctx) { return tcf.has_storage_consent(); } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 139a1c1bc..0a93e6bca 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -801,7 +801,8 @@ impl IntegrationRegistry { 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) + // JS-only modules always included (no Rust-side registration). + // Sourcepoint's JS guards cookie clearing with a Sourcepoint-owned marker. const JS_ALWAYS: &[&str] = &["creative", "sourcepoint"]; let mut ids: Vec<&'static str> = JS_ALWAYS.to_vec(); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 2be659fdc..9a9c24e70 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -225,40 +225,13 @@ Adding a new client-side bidder requires both a config change (`client_side_bidd ## User ID Modules -Prebid's User ID Module resolves cross-publisher identifiers (SharedID, ID5, LiveRamp RampID, UID2, etc.) and exposes them via `pbjs.getUserIdsAsEids()`. The TSJS Prebid integration bundles the core `userId.js` module and a configurable set of ID submodules. When the publisher's origin-side `pbjs.setConfig({ userSync: { userIds: [...] } })` call runs during `processQueue()`, each listed submodule activates and begins resolving its ID asynchronously. After each auction the shim writes the resolved EIDs to a `ts-eids` cookie, which the Rust backend ingests into the Edge Cookie identity graph. +Prebid.js can expose publisher-configured User ID Module output via +`pbjs.getUserIdsAsEids()`. The TSJS Prebid shim reads those current-request +EIDs after auctions and forwards them to Trusted Server when they are available. -### How it works - -1. `userId.js` is statically imported in `index.ts` — always bundled, not operator-configurable. -2. The set of ID submodules is controlled by `TSJS_PREBID_USER_IDS` at build time and emitted into `_user_ids.generated.ts`. -3. Publishers retain full control of which submodules actually run — activation is driven by their own `pbjs.setConfig({ userSync: { userIds: [...] } })` on origin. Bundling a submodule without a matching publisher config entry is inert (but costs bundle size). -4. The `bidsBackHandler` shim calls `pbjs.getUserIdsAsEids()` after each auction and writes the resolved entries to the `ts-eids` cookie (base64-encoded JSON, 3072-byte cap with tail-trim). - -### Build-time submodule selection - -```bash -# Default: the full ship-set of 13 submodules -# (sharedIdSystem, criteoIdSystem, 33acrossIdSystem, pubProvidedIdSystem, -# quantcastIdSystem, id5IdSystem, identityLinkIdSystem, uid2IdSystem, -# euidIdSystem, intentIqIdSystem, lotamePanoramaIdSystem, connectIdSystem, -# merkleIdSystem) - -# Slim build — only SharedID and ID5 -TSJS_PREBID_USER_IDS=sharedIdSystem,id5IdSystem - -# Single submodule -TSJS_PREBID_USER_IDS=sharedIdSystem -``` - -Values are Prebid module filenames without the `.js` extension. The build script (`build-all.mjs`) validates that each exists in `prebid.js/modules/{name}.js` and generates `_user_ids.generated.ts` with the appropriate imports. Unknown names log a warning and are skipped. - -::: warning -`liveIntentIdSystem` is on a build-time denylist — its upstream module uses a dynamic `require()` that esbuild cannot statically resolve, throwing `ReferenceError: require is not defined` at browser runtime. Listing it in `TSJS_PREBID_USER_IDS` logs a warning and skips the module. -::: - -::: tip -Each bundled submodule bloats `tsjs-prebid.js`. If a publisher deployment only needs SharedID and ID5, set `TSJS_PREBID_USER_IDS` accordingly — the other ~100kb of dormant module code won't ship. -::: +Build-time configurable User ID submodule selection is not currently part of the +TSJS build pipeline. Do not rely on a `TSJS_PREBID_USER_IDS` environment +variable or generated `_user_ids.generated.ts` file for slim User ID builds. ## Identity Forwarding diff --git a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md index 2455e12a4..6c23502ea 100644 --- a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md +++ b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md @@ -45,13 +45,18 @@ No Rust-side `IntegrationRegistration` (same pattern as `creative`). 4. Write first-party cookies: - `__gpp=` (path `/`, `SameSite=Lax`) - `__gpp_sid=` (path `/`, `SameSite=Lax`) + - `_ts_gpp_src=sp` marker (path `/`, `SameSite=Lax`) 5. Log what was written for debugging. Cookies are session-scoped (no `max-age` / `expires`) since the source of truth -stays in `localStorage` and we re-mirror on each page load. This design assumes -a single active Sourcepoint property per page; if multiple `_sp_user_consent_*` -entries coexist, the first valid one wins. The integration runs once — no -polling or event listeners. +stays in `localStorage` and we re-mirror on each page load. The marker cookie +tracks Trusted Server's Sourcepoint-owned writes so the integration only clears +`__gpp` / `__gpp_sid` values that it previously mirrored; this avoids clobbering +cookies written by other CMPs. This design assumes a single active Sourcepoint +property per page; if multiple `_sp_user_consent_*` entries coexist, the first +valid one wins. The integration runs immediately, performs bounded first-load +retries, and re-mirrors on page focus/visibility refresh so session cookies do +not remain stale after mid-session consent updates. ### 2. Server-side: GPP US section decoding @@ -104,11 +109,20 @@ if let Some(gpp) = &ctx.gpp { Semantics: -- GPP US `sale_opt_out != OptedOut` → EC allowed -- GPP US `sale_opt_out == OptedOut` → EC blocked -- No GPP US section → falls through to `us_privacy` -- GPC still short-circuits at the top (unchanged) -- TCF still takes priority for CMPs like Didomi (unchanged) +- GPC still short-circuits at the top and blocks EC creation. +- TCF still takes priority for CMPs like Didomi. In US-state jurisdictions, an + effective TCF Purpose 1 signal is treated as the authoritative EC storage + consent decision and is evaluated before GPP US sale opt-out. +- GPP US `sale_opt_out != OptedOut` → EC allowed when no effective TCF signal is + present. +- GPP US `sale_opt_out == OptedOut` → EC blocked when no effective TCF signal is + present. +- No GPP US section → falls through to `us_privacy`. + +The TCF-before-GPP precedence is intentional rather than accidental: it preserves +existing CMP behavior where TCF Purpose 1 is the explicit storage/access signal +for the EC cookie itself. Publishers that need US-section-wins behavior should +raise that as a separate consent-policy configuration change. ### 4. Files touched @@ -120,7 +134,9 @@ Semantics: | `crates/trusted-server-core/src/consent/gpp.rs` | Add US section decoding, extract `sale_opt_out` | | `crates/trusted-server-core/src/consent/mod.rs` | Add GPP US branch in `allows_ec_creation()`, tests | -No config changes, no new crate dependencies, no `IntegrationRegistry` changes. +No config changes and no new crate dependencies. `IntegrationRegistry` includes +`sourcepoint` in the JS-only always-shipped module list; the client-side marker +cookie prevents the always-shipped module from clearing other CMPs' GPP cookies. ### 5. Testing @@ -151,3 +167,4 @@ No config changes, no new crate dependencies, no `IntegrationRegistry` changes. - No richer US GPP field extraction (sharing, targeted advertising opt-outs) - No publisher configuration for Sourcepoint property ID (auto-discovery) - No Sourcepoint CMP API integration (localStorage-only approach) +- No consent-policy knob for making GPP US sale opt-out override TCF Purpose 1 From d0dd47b28f730702fe0cbc179bc28c4a283b368b Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 6 May 2026 15:47:34 -0500 Subject: [PATCH 21/22] Address Sourcepoint review feedback --- .../setup-integration-test-env/action.yml | 2 +- .github/workflows/test.yml | 2 +- .../js/lib/src/integrations/prebid/index.ts | 1 + .../lib/src/integrations/sourcepoint/index.ts | 34 +++++++- .../integrations/sourcepoint/index.test.ts | 85 +++++++++++++++++++ crates/trusted-server-core/src/consent/gpp.rs | 10 +-- ...26-04-15-sourcepoint-gpp-consent-design.md | 5 +- scripts/integration-tests-browser.sh | 2 +- scripts/integration-tests.sh | 2 +- 9 files changed, 128 insertions(+), 15 deletions(-) diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index a491eb6e1..9c95f8bb5 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -80,7 +80,7 @@ runs: env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret + TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1dd8f0323..2da273aa0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080 TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret + TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index b8d6d73b6..fbc798c20 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -451,6 +451,7 @@ function fitAuctionEidsToCookie(eids: AuctionEid[]): AuctionEid[] | undefined { function syncPrebidEidsCookie(): void { try { if (typeof pbjs.getUserIdsAsEids !== 'function') { + // Without Prebid EIDs to forward, stale auction fallback IDs must not persist. clearPrebidEidsCookie(); return; } diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts index 0e31b0869..f84131349 100644 --- a/crates/js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -17,6 +17,8 @@ interface SourcepointConsentPayload { } let initialized = false; +let initialRetryDone = false; +let retryTimer: ReturnType | undefined; function findSourcepointConsent(): SourcepointConsentPayload | null { // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. @@ -74,8 +76,27 @@ function mirrorOnVisible(): void { } } +function clearInitialRetryTimer(): void { + if (retryTimer === undefined) { + return; + } + + window.clearTimeout(retryTimer); + retryTimer = undefined; +} + function scheduleInitialRetry(): void { + if (initialRetryDone || retryTimer !== undefined) { + return; + } + const retry = (): void => { + if (initialRetryDone) { + return; + } + + initialRetryDone = true; + clearInitialRetryTimer(); mirrorSourcepointConsent(); }; @@ -83,7 +104,7 @@ function scheduleInitialRetry(): void { document.addEventListener('DOMContentLoaded', retry, { once: true }); } - window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); + retryTimer = window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); } /** @@ -111,15 +132,24 @@ export function mirrorSourcepointConsent(): boolean { return false; } + const existingGppCookie = readCookie(GPP_COOKIE_NAME); + if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { + log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); + return false; + } + writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); writeCookie(GPP_COOKIE_NAME, gppString); if (Array.isArray(applicableSections) && applicableSections.length > 0) { writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); - } else if (hasSourcepointMarker()) { + } else { clearCookie(GPP_SID_COOKIE_NAME); } + initialRetryDone = true; + clearInitialRetryTimer(); + log.info('sourcepoint: mirrored GPP consent to cookies', { gppLength: gppString.length, sections: applicableSections, diff --git a/crates/js/lib/test/integrations/sourcepoint/index.test.ts b/crates/js/lib/test/integrations/sourcepoint/index.test.ts index 3914c2ef9..21dbc1785 100644 --- a/crates/js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -34,6 +34,7 @@ describe('integrations/sourcepoint', () => { afterEach(() => { vi.useRealTimers(); + Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); clearAllCookies(); localStorage.clear(); }); @@ -82,6 +83,22 @@ describe('integrations/sourcepoint', () => { expect(getCookie('__gpp_sid')).toBe('7,8'); }); + it('does not overwrite GPP cookies owned by another CMP', () => { + document.cookie = '__gpp=other-cmp-gpp; path=/'; + document.cookie = '__gpp_sid=2; path=/'; + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('sourcepoint-gpp', [7])) + ); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(false); + expect(getCookie('__gpp')).toBe('other-cmp-gpp'); + expect(getCookie('__gpp_sid')).toBe('2'); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBeUndefined(); + }); + it('clears stale Sourcepoint-owned mirrored cookies when no valid Sourcepoint payload exists', () => { document.cookie = '__gpp=stale-gpp; path=/'; document.cookie = '__gpp_sid=7,8; path=/'; @@ -150,6 +167,23 @@ describe('integrations/sourcepoint', () => { expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); }); + it('updates GPP cookies when Sourcepoint owns the marker', () => { + document.cookie = '__gpp=stale-sourcepoint-gpp; path=/'; + document.cookie = '__gpp_sid=7; path=/'; + document.cookie = `${SOURCEPOINT_MARKER_COOKIE}=sp; path=/`; + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('updated-sourcepoint-gpp', [8])) + ); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('updated-sourcepoint-gpp'); + expect(getCookie('__gpp_sid')).toBe('8'); + expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); + }); + it('refreshes mirrored cookies when the window regains focus', () => { localStorage.setItem( '_sp_user_consent_12345', @@ -199,4 +233,55 @@ describe('integrations/sourcepoint', () => { expect(getCookie('__gpp')).toBe('retry-gpp'); expect(getCookie('__gpp_sid')).toBe('7'); }); + + it('clears a pending initial retry after a successful manual mirror', async () => { + vi.useFakeTimers(); + vi.resetModules(); + localStorage.clear(); + clearAllCookies(); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + + const sourcepoint = await import('../../../src/integrations/sourcepoint'); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('manual-gpp', [7])) + ); + expect(sourcepoint.mirrorSourcepointConsent()).toBe(true); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('timer-gpp', [8])) + ); + document.dispatchEvent(new Event('DOMContentLoaded')); + vi.advanceTimersByTime(500); + + expect(getCookie('__gpp')).toBe('manual-gpp'); + expect(getCookie('__gpp_sid')).toBe('7'); + }); + + it('does not run both DOMContentLoaded and timer retries', async () => { + vi.useFakeTimers(); + vi.resetModules(); + localStorage.clear(); + clearAllCookies(); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + + await import('../../../src/integrations/sourcepoint'); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('domcontentloaded-gpp', [7])) + ); + document.dispatchEvent(new Event('DOMContentLoaded')); + + localStorage.setItem( + '_sp_user_consent_12345', + JSON.stringify(sourcepointPayload('timer-gpp', [8])) + ); + vi.advanceTimersByTime(500); + + expect(getCookie('__gpp')).toBe('domcontentloaded-gpp'); + expect(getCookie('__gpp_sid')).toBe('7'); + }); }); diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs index a6a9766d6..ffb770c2f 100644 --- a/crates/trusted-server-core/src/consent/gpp.rs +++ b/crates/trusted-server-core/src/consent/gpp.rs @@ -123,7 +123,7 @@ const US_SECTION_ID_RANGE: std::ops::RangeInclusive = 7..=23; /// - `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; + let mut result = None; for us_section_id in parsed .section_ids() @@ -132,7 +132,7 @@ fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { 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, + Some(false) => result = Some(false), None => {} }, Err(e) => { @@ -141,11 +141,7 @@ fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option { } } - if saw_not_opted_out { - Some(false) - } else { - None - } + result } fn us_sale_opt_out_from_section(section: &iab_gpp::sections::Section) -> Option { diff --git a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md index 6c23502ea..725857db5 100644 --- a/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md +++ b/docs/superpowers/specs/2026-04-15-sourcepoint-gpp-consent-design.md @@ -129,14 +129,15 @@ raise that as a separate consent-policy configuration change. | File | Change | |---|---| | `crates/js/lib/src/integrations/sourcepoint/index.ts` | New — localStorage auto-discovery, cookie mirroring | -| `crates/js/lib/src/integrations/sourcepoint/index.test.ts` | New — Vitest tests | +| `crates/js/lib/test/integrations/sourcepoint/index.test.ts` | New — Vitest tests | | `crates/trusted-server-core/src/consent/types.rs` | Add `us_sale_opt_out: Option` to `GppConsent` | | `crates/trusted-server-core/src/consent/gpp.rs` | Add US section decoding, extract `sale_opt_out` | | `crates/trusted-server-core/src/consent/mod.rs` | Add GPP US branch in `allows_ec_creation()`, tests | No config changes and no new crate dependencies. `IntegrationRegistry` includes `sourcepoint` in the JS-only always-shipped module list; the client-side marker -cookie prevents the always-shipped module from clearing other CMPs' GPP cookies. +cookie prevents the always-shipped module from clearing or overwriting other +CMPs' GPP cookies. ### 5. Testing diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index fb1289d3e..46555fcf9 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -32,7 +32,7 @@ echo "==> Validating shared integration-test dependency versions..." echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ -TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret" \ +TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 318b9323c..6f40f62be 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -53,7 +53,7 @@ fi echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ -TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret" \ +TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 From 86b9453d845fc7289b21519a4389569087e508c7 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 7 May 2026 15:23:36 -0500 Subject: [PATCH 22/22] Isolate EC lifecycle integration seeds --- .../fixtures/configs/viceroy-template.toml | 20 ++++++++++++- .../tests/frameworks/scenarios.rs | 28 +++++++++++-------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/integration-tests/fixtures/configs/viceroy-template.toml b/crates/integration-tests/fixtures/configs/viceroy-template.toml index b7109b123..086e3e4f3 100644 --- a/crates/integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/integration-tests/fixtures/configs/viceroy-template.toml @@ -25,11 +25,29 @@ key = "placeholder" data = "placeholder" - # Pre-seeded EC row for KV-backed EC lifecycle tests. + # Pre-seeded EC rows for KV-backed EC lifecycle tests. Each scenario + # uses a separate row so withdrawal tombstones do not leak across + # sequential scenario execution in the same Viceroy instance. [[local_server.kv_stores.ec_identity_store]] key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01" data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + [[local_server.kv_stores.ec_identity_store]] + key = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.test02" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.test03" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.test04" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.test05" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + [[local_server.kv_stores.ec_partner_store]] key = "placeholder" data = "placeholder" diff --git a/crates/integration-tests/tests/frameworks/scenarios.rs b/crates/integration-tests/tests/frameworks/scenarios.rs index 558dfc501..8fc112fae 100644 --- a/crates/integration-tests/tests/frameworks/scenarios.rs +++ b/crates/integration-tests/tests/frameworks/scenarios.rs @@ -500,16 +500,17 @@ impl EcScenario { /// US Privacy signal that explicitly allows storage in the default Viceroy /// integration-test geo (US-CA). const ALLOW_US_PRIVACY_COOKIE: &str = "1YNN"; -const SEEDED_EC_ID: &str = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01"; - fn allow_ec_generation(client: &EcTestClient) { client.set_cookie("us_privacy", ALLOW_US_PRIVACY_COOKIE); } -fn use_seeded_ec(client: &EcTestClient) -> String { - client.set_cookie("ts-ec", SEEDED_EC_ID); - normalize_ec_id(SEEDED_EC_ID) +fn seeded_ec_id(hex_digit: char, suffix: &str) -> String { + format!("{}.{suffix}", hex_digit.to_string().repeat(64)) +} + +fn use_seeded_ec(client: &EcTestClient, ec_id: &str) -> String { + client.set_cookie("ts-ec", ec_id); + normalize_ec_id(ec_id) } /// Full lifecycle: seeded EC → batch sync → identify (Bearer auth) with scoped UID. @@ -518,7 +519,8 @@ fn use_seeded_ec(client: &EcTestClient) -> String { fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('a', "test01"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC full lifecycle: using seeded EC ID = {ec_id}"); // 2. Batch sync writes partner UID (partner "inttest" is in config) @@ -576,7 +578,8 @@ fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('b', "test02"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC consent withdrawal: using seeded EC = {ec_id}"); // GPC overrides the allow cookie in US-CA, so this is an explicit @@ -623,7 +626,8 @@ fn ec_identify_without_ec(base_url: &str) -> TestResult<()> { fn ec_identify_consent_denied(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let _ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('c', "test03"); + let _ec_id = use_seeded_ec(&client, &seeded_ec_id); // Identify with GPC=1 — in the default US-CA test geo, GPC is an explicit // denial that must override the allow cookie. Per spec §11.4, consent is @@ -647,7 +651,8 @@ fn ec_identify_consent_denied(base_url: &str) -> TestResult<()> { fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('d', "test04"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC concurrent syncs: using seeded EC = {ec_id}"); // Batch sync both partners (both are pre-configured in trusted-server.toml) @@ -705,7 +710,8 @@ fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> { fn ec_batch_sync_happy_path(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); - let ec_id = use_seeded_ec(&client); + let seeded_ec_id = seeded_ec_id('e', "test05"); + let ec_id = use_seeded_ec(&client, &seeded_ec_id); log::info!("EC batch sync happy path: using seeded ec_id = {ec_id}"); // Batch sync writes a UID for this EC ID (partner "inttest" is in config)