From 6d49771c73eb0f40d8baa775285f0653f5c3253a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:08:38 +0530 Subject: [PATCH 01/26] docs: specify LiveRamp integration --- .../2026-08-21-liveramp-integration-design.md | 553 ++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-21-liveramp-integration-design.md diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md new file mode 100644 index 000000000..13679c0a7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -0,0 +1,553 @@ +# LiveRamp Integration Design + +**Issue:** [#355 — Investigate and document LiveRamp integration](https://github.com/IABTechLab/trusted-server/issues/355) + +**Parent epic:** [#354 — LiveRamp integration](https://github.com/IABTechLab/trusted-server/issues/354) + +**Initiative:** [#55 — Monetization integrations](https://github.com/IABTechLab/trusted-server/issues/55) + +**Status:** Proposed + +**Date:** 2026-08-21 + +## 1. Executive summary + +LiveRamp integration is feasible in two distinct forms, but they must not be +treated as one protocol: + +1. **RampID identity-envelope forwarding through Prebid.js is feasible now.** + Trusted Server already bundles Prebid's `identityLinkIdSystem`, reads + `liveramp.com` EIDs through `pbjs.getUserIdsAsEids()`, sends them to + `/auction`, merges them with EC/KV identities, applies consent gating, and + forwards them to Prebid Server as OpenRTB `user.ext.eids`. +2. **LiveRamp ATS Direct audience segments are not part of that EID flow.** ATS + Direct returns a separate segment envelope and has separate subscription, + storage, TTL, deal-approval, and activation requirements. The cited + LiveRamp documentation describes activating these values as GAM `atsd` + targeting, not as a `liveramp.com` EID. + +The first implementation should make the existing RampID path operationally +complete by adding typed LiveRamp configuration under the Prebid integration, +injecting a deterministic `identityLink` User ID configuration, validating the +generated bundle, and documenting a live verification procedure. Native +server-to-server ATS resolution and ATS Direct segment activation remain +separate follow-up decisions. + +## 2. Issue hierarchy and collected requirements + +The GitHub issue hierarchy is: + +```text +#55 Initiative: Monetization integrations +└── #354 Epic: LiveRamp integration + └── #355 Task: Investigate and document LiveRamp integration + └── IABTechLab/uid2-optout#385: Get test credentials from LR team +``` + +The three trusted-server issues have empty or placeholder bodies, so their +comments and linked documentation define the operative requirements. + +### 2.1 Issue #354 + +The only comment asks the team to confirm whether LiveRamp segments are passed +to auction requests through the Prebid.js integration. This specification must +therefore distinguish identity envelopes from segment data and answer both +questions explicitly. + +### 2.2 Issue #355 + +The comments establish the following sequence and requirements: + +1. Review LiveRamp's Real-Time Identity Service (RTIS) tag documentation. +2. Wait for LiveRamp to clarify the integration. +3. Test the documentation LiveRamp supplied. +4. Review the ATS Envelope API page LiveRamp recommended. +5. Write a specification and determine feasibility. + +The issue's direct deliverable is an evidence-backed specification. If the +recommended path is feasible, implementation follows the approved design. + +### 2.3 Credential dependency + +Issue #355 has a cross-repository child, +[IABTechLab/uid2-optout#385](https://github.com/IABTechLab/uid2-optout/issues/385), +named “Get test credentials from LR team.” It remains open. Its only comment +says that LiveRamp would be emailed. + +Automated tests must not depend on LiveRamp credentials. A live Placement ID +and a LiveRamp-approved test origin are nevertheless required for final +end-to-end verification against LiveRamp. + +## 3. Terminology and product boundaries + +### 3.1 RampID identity envelope + +Prebid's LiveRamp module is named `identityLinkIdSystem`, its configuration name +is `identityLink`, and its EID source is `liveramp.com`. It resolves an encrypted +RampID envelope into Prebid's identity APIs. The envelope identifies a user to +authorized demand partners; Trusted Server treats the value as opaque. + +### 3.2 RTIS + +LiveRamp's Real-Time Identity Service tag is a pixel or JavaScript tag that uses +LiveRamp cookie recognition and redirects a RampID to an endpoint registered +with LiveRamp. It requires LiveRamp to configure a tag ID and callback endpoint. +Trusted Server has no RTIS callback route today. + +RTIS is not selected for the first implementation because the managed Prebid +module already provides the browser-to-bidstream path, while a new callback +would require correlation, endpoint authentication, storage, abuse protection, +and a LiveRamp-specific server contract. + +### 3.3 ATS Envelope API + +The ATS Envelope API resolves hashed email, hashed phone, or configured custom +IDs into one or more encrypted envelopes. A server-to-server call requires a +Placement ID, a privacy-approved Origin, consent parameters where applicable, +and the browser's client IP in `X-Forwarded-For`. + +The ordinary ATS response contains an identity envelope with `type: 19` and +`source: "envelopeLiveramp"`. A no-consent response is HTTP 204. Configuration, +authorization, service, and geographic/consent failures use distinct 4xx +statuses. + +### 3.4 ATS Direct segments + +ATS Direct is a separate product layered onto an approved ATS placement and +subscription. Its V2 response can include `type: 26`, `source: "atsDirect"`, +whose value represents matching deal/segment IDs. LiveRamp documents storing +this in `_lr_atsDirect`, maintaining a region-dependent TTL, refreshing it, and +applying selected deal IDs to GAM under the `atsd` targeting key. + +An ATS Direct segment envelope is not a RampID and must not be placed in +`user.ext.eids` under `liveramp.com`. + +## 4. Current Trusted Server capabilities + +The following capabilities already exist on `main`: + +- `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json` + includes `identityLinkIdSystem` in the default preset, maps the Prebid config + name `identityLink`, and maps EID source `liveramp.com`. +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` reads + `pbjs.getUserIdsAsEids()`, validates EID structure, and includes valid EIDs in + the current `/auction` request. +- The same TSJS module persists structured OpenRTB-style EIDs in the first-party + `ts-eids` cookie after auction completion. +- `crates/trusted-server-core/src/auction/endpoints.rs` parses current-request + EIDs, loads server-resolved EIDs from the EC/KV graph, merges and deduplicates + them, and applies centralized consent gating. +- `crates/trusted-server-core/src/integrations/prebid.rs` serializes the merged + set to Prebid Server as OpenRTB `user.ext.eids`. +- `crates/trusted-server-core/src/ec/prebid_eids.rs` ingests `ts-eids` on a + later request and maps configured sources such as `liveramp.com` into the + EC/KV identity graph. +- The external bundle manifest and runtime diagnostics already identify which + Prebid User ID modules were compiled into the bundle. + +### 4.1 Current gap + +Trusted Server includes the module but does not configure it. There is no typed +operator setting for LiveRamp's Placement ID or the module's storage behavior. +The integration only works when publisher JavaScript independently queues the +correct `pbjs.setConfig({ userSync: { userIds: [...] } })` call. + +That is not a complete managed integration: configuration can be lost when the +publisher's Prebid asset is intercepted, deployments cannot validate it, and +operators cannot audit it alongside the generated bundle manifest. + +## 5. Approaches considered + +### 5.1 Selected: typed LiveRamp configuration within Prebid + +Add an optional LiveRamp subsection to `PrebidIntegrationConfig`, inject it +through `window.__tsjs_prebid`, and let the TSJS Prebid shim configure the +existing `identityLinkIdSystem` before queued Prebid work is processed. + +Benefits: + +- Uses the existing module, bundle generator, EID transport, consent gate, and + EC/KV ingestion path. +- Keeps browser identity configuration beside the Prebid bundle that consumes + it. +- Adds no new upstream route or PII-bearing server API. +- Can be fully tested without external credentials, with a separate live + verification gate. + +Trade-off: this only resolves identities visible to the browser module; it does +not add server-side HEM resolution or ATS Direct segments. + +### 5.2 Rejected for the first implementation: standalone LiveRamp integration + +A new `integrations/liveramp` module could own browser and server APIs. This is +premature because only the Prebid browser path is approved, while the ATS API +input contract and ATS Direct product scope remain unresolved. It would also +duplicate Prebid lifecycle and bundle validation responsibilities. + +Revisit this boundary if a future approved design adds server-to-server ATS +resolution or a non-Prebid LiveRamp consumer. + +### 5.3 Rejected: RTIS callback endpoint + +An RTIS endpoint would introduce a new unauthenticated redirect/callback +surface and a correlation problem without improving the already-supported +Prebid identity path. LiveRamp also requires per-endpoint configuration. It is +not justified for the current requirement. + +### 5.4 Deferred: native server-to-server ATS resolution + +Native resolution is technically possible with Trusted Server's platform HTTP +abstractions, consent context, geo context, and client IP access. It is not +implementation-ready because: + +- Trusted Server has no approved source for hashed email, hashed phone, or a + LiveRamp custom ID. +- Sending a hashed identifier to LiveRamp is a privacy and publisher-contract + decision, not merely a transport detail. +- LiveRamp credentials/configuration and an approved Origin are unavailable. +- Rate limits, timeout policy, caching, envelope refresh, and identifier + deletion semantics are not confirmed. +- [#630 — HEM Resolution (LiveRamp)](https://github.com/IABTechLab/trusted-server/issues/630) + was closed as not planned and must not be silently revived. + +## 6. Proposed configuration + +LiveRamp configuration is optional and nested under the existing Prebid +integration: + +```toml +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" +external_bundle_sha256 = "" +external_bundle_sri = "sha256-" + +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 +``` + +The Rust representation is an optional field on `PrebidIntegrationConfig`: + +```rust +pub struct PrebidIntegrationConfig { + // Existing fields omitted. + pub liveramp: Option, +} + +pub struct PrebidLiveRampConfig { + pub placement_id: String, + pub not_use_3p: bool, + pub storage_type: PrebidLiveRampStorageType, + pub expires_days: u16, + pub refresh_in_seconds: u32, +} + +pub enum PrebidLiveRampStorageType { + Cookie, + Html5, +} +``` + +Defaults: + +| Field | Default | Reason | +| -------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | +| `not_use_3p` | `false` | Matches the documented Prebid/LiveRamp example and permits RTIS until authentication replaces it with ATS. | +| `storage_type` | `cookie` | Provides first-party browser persistence and matches the documented example. | +| `expires_days` | `15` | LiveRamp's conservative recommendation for GDPR/CCPA traffic. | +| `refresh_in_seconds` | `1800` | LiveRamp recommends refreshing rotating encrypted envelopes every 30 minutes. | + +`placement_id` is required when the subsection exists. It must be non-empty, +trimmed, and contain only ASCII digits. `expires_days` must be from 1 through 30. `refresh_in_seconds` must be non-zero. The storage name is deliberately not +configurable: the LiveRamp/Prebid contract requires `idl_env`. + +Omitting `[integrations.prebid.liveramp]` preserves current behavior and emits +no LiveRamp configuration. + +## 7. Browser configuration and ordering + +The Rust Prebid head injector extends `window.__tsjs_prebid` with a camel-cased +`liveRamp` object containing the validated values. The Placement ID is an +operator identifier rather than a secret, but diagnostics must not copy +envelope values. + +The TSJS Prebid shim translates the injected object into: + +```javascript +{ + userSync: { + userIds: [ + { + name: 'identityLink', + params: { + pid: '999', + notUse3P: false, + }, + storage: { + type: 'cookie', + name: 'idl_env', + expires: 15, + refreshInSeconds: 1800, + }, + }, + ] + } +} +``` + +Publisher commands may already be waiting in `window.pbjs.que`. The managed +LiveRamp configuration must be appended to that queue before +`pbjs.processQueue()` rather than applied immediately. When executed, it reads +the effective `userSync.userIds`, preserves unrelated publisher User ID +entries, and replaces or appends exactly one `identityLink` entry with the +operator-managed values. This ordering makes the Trusted Server configuration +deterministic without deleting other publisher identity modules. + +After queue processing, the existing bundle diagnostics confirm that +`identityLinkIdSystem` is present in the external bundle. A missing module is a +configuration error surfaced through existing TSJS diagnostics and logging; +the auction itself continues without a LiveRamp EID. + +## 8. Data flow + +```mermaid +sequenceDiagram + participant O as Operator config + participant TS as Trusted Server + participant B as Browser + participant LR as LiveRamp + participant PBS as Prebid Server + participant KV as EC identity graph + + O->>TS: Configure integrations.prebid.liveramp + TS-->>B: Inject liveRamp config and managed Prebid bundle + B->>B: Queue identityLink configuration + B->>LR: Prebid identityLink module resolves/refreshes envelope + LR-->>B: Opaque RampID envelope + B->>B: pbjs.getUserIdsAsEids() + B->>TS: POST /auction with source=liveramp.com EID + TS->>TS: Validate, merge, deduplicate, consent-gate EIDs + TS->>PBS: OpenRTB user.ext.eids + B->>B: Persist structured EIDs in ts-eids after auction + B->>TS: Later request with ts-eids + ts-ec + TS->>KV: Upsert configured liveramp.com partner UID +``` + +Identity resolution is asynchronous. The design does not promise a LiveRamp +EID in the first auction on a new browser. Current-request forwarding applies +as soon as `getUserIdsAsEids()` exposes the envelope; `ts-eids` and EC/KV +ingestion provide reuse on later requests. + +## 9. Consent, privacy, and security + +- Trusted Server continues to apply its centralized consent gate before EIDs + reach providers. No LiveRamp-specific bypass is introduced. +- Prebid's User ID and consent-management modules remain responsible for + deciding whether the browser may call LiveRamp. LiveRamp must be configured + correctly in the publisher's CMP/GVL posture. +- LiveRamp envelope values are opaque identifiers. They must never appear in + logs, public diagnostics, error bodies, or telemetry dimensions. +- The implementation does not collect plaintext or hashed email and does not + add an API for publishers to submit either value. +- The managed configuration preserves unrelated publisher User ID entries but + owns the single `identityLink` entry when enabled. This prevents ambiguous + duplicate LiveRamp configurations. +- Existing EID size limits, source/UID validation, cookie caps, merge rules, + and consent withdrawal behavior remain authoritative. +- Live credentials and Placement IDs must not be committed to fixtures or + repository configuration. + +## 10. Error and degraded behavior + +| Condition | Behavior | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| LiveRamp subsection absent | Preserve current behavior; configure no `identityLink` entry. | +| Invalid Placement ID or unsafe bounds | Reject configuration at startup/validation time. | +| `identityLinkIdSystem` missing from external bundle | Emit existing missing-module diagnostics; continue auctions without LiveRamp EID. | +| LiveRamp network or recognition failure | Prebid module yields no EID; continue auction normally. | +| No consent or opted-out user | Forward no LiveRamp EID; continue auction normally. | +| Malformed LiveRamp EID | Existing client/server EID sanitizers drop it. | +| Oversized `ts-eids` payload | Existing bounded cookie behavior truncates whole UID/source entries; no partial UID is written. | +| EC/KV unavailable | Current-request EID can still reach `/auction`; persistence degrades without blocking the auction. | + +Trusted Server does not parse LiveRamp envelope contents and therefore cannot +distinguish authenticated ATS envelopes from cookie-recognized RTIS envelopes. +That distinction remains inside LiveRamp's module and encrypted envelope. + +## 11. Testing strategy + +Implementation follows test-driven development. + +### 11.1 Rust configuration tests + +Add tests in `crates/trusted-server-core/src/integrations/prebid.rs` and the +settings tests to prove: + +- a complete LiveRamp subsection deserializes with expected values; +- documented defaults are applied; +- missing, blank, whitespace-padded, or nonnumeric Placement IDs fail; +- invalid expiry and zero refresh values fail; +- unknown storage types fail; +- omission remains backward-compatible; +- serialized head configuration uses the expected camel-cased keys; +- script-breaking input cannot escape the injected script element. + +### 11.2 TypeScript unit tests + +Add tests in +`crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` proving: + +- no LiveRamp config produces no `identityLink` entry; +- enabled config creates the exact documented Prebid object; +- an unrelated publisher `userIds` list is preserved; +- a publisher-provided `identityLink` entry is replaced, not duplicated; +- managed configuration runs after earlier queued publisher configuration; +- missing `identityLinkIdSystem` appears in existing diagnostics; +- `getUserIdsAsEids()` output for `liveramp.com` enters the current auction; +- malformed and empty envelope values are dropped; +- envelope values are not written to logs or diagnostics; +- the existing `ts-eids` persistence path preserves the opaque value without + decoding it. + +### 11.3 Bundle tests + +Extend external bundle tests to prove: + +- the default preset contains `identityLinkIdSystem`; +- explicitly selecting it stamps the module into the manifest; +- selecting a bundle without it produces a deterministic diagnostic when + LiveRamp configuration is present. + +### 11.4 Rust auction/EC regression tests + +Existing generic EID tests cover most transport behavior. Add or retain a +LiveRamp-named fixture proving that a `liveramp.com` EID: + +- is forwarded as `user.ext.eids` to the Prebid provider; +- merges without duplication against the EC/KV version; +- is removed when consent denies identity forwarding; +- is ingested into the configured `liveramp.com` EC partner namespace on a + later request. + +### 11.5 Live credential validation + +Run outside CI against a LiveRamp-approved non-production origin: + +1. Obtain a test Placement ID and confirm the origin is approved. +2. Generate a Prebid bundle containing `identityLinkIdSystem`. +3. Configure `[integrations.prebid.liveramp]` with the test Placement ID. +4. Load the publisher page with positive consent. +5. Confirm `idl_env` is created or refreshed according to the selected storage. +6. Confirm `pbjs.getUserIdsAsEids()` returns a `liveramp.com` entry without + recording its value. +7. Inspect a controlled Prebid Server request and confirm the same source is + present in `user.ext.eids`. +8. Confirm a later request can ingest the EID into the configured EC partner. +9. Repeat with opt-out/no-consent and confirm no LiveRamp EID is forwarded. +10. Repeat with an unapproved origin and document the expected degraded result. + +Record only booleans, source names, counts, and status codes. Do not capture or +publish live envelopes. + +## 12. Documentation changes + +Implementation updates: + +- `trusted-server.example.toml` with a commented LiveRamp example; +- `docs/guide/integrations/prebid.md` with configuration, lifecycle, bundle, + consent, troubleshooting, and verification guidance; +- `docs/guide/configuration.md` with the typed field reference; +- optionally a short `docs/guide/integrations/liveramp.md` page if the Prebid + guide would become difficult to navigate. The first implementation should + avoid duplicating the authoritative Prebid flow across two pages. + +The documentation must state that: + +- RampID envelopes, not audience segments, are forwarded as EIDs; +- a Placement ID and LiveRamp-approved origin are operational prerequisites; +- the first auction may not contain a newly resolved identity; +- a module included in a bundle is inert until configured; +- ATS Direct segments require separate enablement and implementation. + +## 13. Rollout and observability + +1. Land configuration and tests with the subsection absent by default. +2. Generate and publish a test bundle that includes `identityLinkIdSystem`. +3. Validate on a non-production approved origin with debug logging restricted + to source names/counts. +4. Enable for a canary publisher property. +5. Monitor missing-module diagnostics, LiveRamp EID presence counts, auction + error rates, and cookie/header size truncation counts. Never dimension + metrics by envelope value. +6. Validate opt-out behavior before broader rollout. +7. Document the tested Placement/origin configuration in operator-owned, + non-repository deployment records. + +No database or KV migration is required. Omitting the new subsection provides +an immediate configuration rollback. + +## 14. Acceptance criteria + +Issue #355's implementation portion is complete when: + +- operators can configure LiveRamp RampID through typed Trusted Server config; +- invalid configuration fails before serving traffic; +- managed configuration preserves non-LiveRamp publisher User ID modules and + owns one deterministic `identityLink` entry; +- bundle diagnostics detect a missing `identityLinkIdSystem`; +- valid `liveramp.com` EIDs follow the existing browser → `/auction` → Prebid + Server path without exposing envelope contents; +- existing consent, validation, merge, cookie, and EC/KV behavior is preserved; +- automated Rust and TypeScript tests pass; +- operator documentation explains setup, timing, privacy, failure behavior, + and live verification; +- credential-based validation is completed, or the remaining credential block + is explicitly recorded with an owner and the implementation is labeled + “code complete, live validation pending”; and +- the parent epic receives the explicit answer: RampID identity envelopes can + be passed through the Prebid auction path; ATS Direct segments are not passed + by this implementation. + +## 15. Out of scope and follow-up work + +### 15.1 Server-to-server ATS resolution + +Create or reopen a dedicated issue only after product approval. Its design must +define the hashed-identifier source, origin approval, consent mapping, +`X-Forwarded-For` handling, timeout/cache/refresh policy, geographic failure +behavior, data deletion, and credential storage. It must also reconcile the +decision that closed #630 as not planned. + +### 15.2 ATS Direct audience segments + +Create a separate issue if publishers require LiveRamp segment activation. It +must define: + +- ATS Direct subscription and approved-deal prerequisites; +- whether the integration calls the API or consumes existing browser storage; +- `_lr_atsDirect` and TTL ownership; +- refresh behavior and regional TTL rules; +- whether activation targets GAM (`atsd`), Prebid first-party data, a Prebid + real-time-data module, or more than one destination; +- consent and deletion behavior; and +- the exact evidence needed to confirm segment delivery. + +### 15.3 RTIS callback + +Do not add an RTIS callback unless a concrete non-Prebid use case demonstrates +that the browser module is insufficient and LiveRamp approves the endpoint +contract. + +## 16. Authoritative references + +- [LiveRamp: Implementing the Real-Time Identity Service Tag](https://docs.liveramp.com/identity/en/implementing-liveramp-s-real-time-identity-service-tag.html) +- [LiveRamp: Call the ATS Envelope API](https://developers.liveramp.com/authenticatedtraffic-api/docs/4-call-the-ats-envelope-api) +- [LiveRamp: Retrieving Envelope Endpoints](https://developers.liveramp.com/authenticatedtraffic-api/v1.0/docs/about-the-ats-api) +- [LiveRamp: ATS Direct](https://developers.liveramp.com/authenticatedtraffic-api/docs/implement-ats-direct-via-api) +- [Prebid: LiveRamp RampID User ID module](https://docs.prebid.org/dev-docs/modules/userid-submodules/ramp.html) +- [Prebid: User ID module](https://docs.prebid.org/dev-docs/modules/userId.html) From 22fef998aec41c1eb5ada5803090cff6936ae264 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:12:19 +0530 Subject: [PATCH 02/26] docs: clarify LiveRamp config ownership --- .../2026-08-21-liveramp-integration-design.md | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md index 13679c0a7..b1659aec2 100644 --- a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -161,8 +161,9 @@ operators cannot audit it alongside the generated bundle manifest. ### 5.1 Selected: typed LiveRamp configuration within Prebid Add an optional LiveRamp subsection to `PrebidIntegrationConfig`, inject it -through `window.__tsjs_prebid`, and let the TSJS Prebid shim configure the -existing `identityLinkIdSystem` before queued Prebid work is processed. +through `window.__tsjs_prebid`, and let the TSJS Prebid shim install an +operator-owned `identityLinkIdSystem` configuration before queued Prebid work +is processed. Benefits: @@ -300,13 +301,32 @@ The TSJS Prebid shim translates the injected object into: } ``` -Publisher commands may already be waiting in `window.pbjs.que`. The managed -LiveRamp configuration must be appended to that queue before -`pbjs.processQueue()` rather than applied immediately. When executed, it reads -the effective `userSync.userIds`, preserves unrelated publisher User ID -entries, and replaces or appends exactly one `identityLink` entry with the -operator-managed values. This ordering makes the Trusted Server configuration -deterministic without deleting other publisher identity modules. +Publisher commands may already be waiting in `window.pbjs.que`, including a +`requestBids` command. Appending the managed configuration would be too late: +Prebid processes existing commands in insertion order, so a publisher auction +could run before the new entry. + +When LiveRamp is configured, the shim instead installs a narrowly scoped, +idempotent wrapper around `pbjs.setConfig` before calling `pbjs.processQueue()`: + +1. Capture and bind the real `pbjs.setConfig` implementation. +2. Replace `pbjs.setConfig` with a wrapper that only normalizes calls containing + `userSync.userIds`. Other configuration calls pass through unchanged. +3. For a `userSync.userIds` call, preserve every non-`identityLink` entry, + remove all publisher-supplied `identityLink` entries, and append exactly one + operator-managed entry. Preserve sibling `userSync` and top-level fields. +4. Apply the operator-managed entry synchronously through the captured function + before processing any existing queue entries. +5. Call `pbjs.processQueue()`. Queued publisher `setConfig` calls flow through + the wrapper, so a later queued `requestBids` observes the managed entry. +6. Keep the wrapper installed after queue processing so later publisher + `setConfig` calls cannot silently replace or delete the operator-owned + LiveRamp policy. Repeated TSJS installation must not stack wrappers. + +This policy gives the operator ownership of the single `identityLink` entry +when `[integrations.prebid.liveramp]` exists. Publishers retain ownership of all +other Prebid and User ID configuration. Omitting the subsection installs no +wrapper and preserves current publisher behavior exactly. After queue processing, the existing bundle diagnostics confirm that `identityLinkIdSystem` is present in the external bundle. A missing module is a @@ -406,7 +426,14 @@ Add tests in - enabled config creates the exact documented Prebid object; - an unrelated publisher `userIds` list is preserved; - a publisher-provided `identityLink` entry is replaced, not duplicated; -- managed configuration runs after earlier queued publisher configuration; +- managed configuration is active before an already-queued publisher + `requestBids` call; +- queued publisher `setConfig` followed by `requestBids` preserves other User + ID entries while the auction observes the managed `identityLink` entry; +- a publisher `identityLink` update after `processQueue()` is normalized back + to the operator-managed values; +- repeated installation does not stack `setConfig` wrappers; +- configuration calls unrelated to `userSync.userIds` pass through unchanged; - missing `identityLinkIdSystem` appears in existing diagnostics; - `getUserIdsAsEids()` output for `liveramp.com` enters the current auction; - malformed and empty envelope values are dropped; From 675cbddbe2e50bf77dbacf7a8e7461d2791e91c1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:14:05 +0530 Subject: [PATCH 03/26] docs: preserve preconfigured Prebid user IDs --- .../2026-08-21-liveramp-integration-design.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md index b1659aec2..ee04b04e5 100644 --- a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -315,8 +315,13 @@ idempotent wrapper around `pbjs.setConfig` before calling `pbjs.processQueue()`: 3. For a `userSync.userIds` call, preserve every non-`identityLink` entry, remove all publisher-supplied `identityLink` entries, and append exactly one operator-managed entry. Preserve sibling `userSync` and top-level fields. -4. Apply the operator-managed entry synchronously through the captured function - before processing any existing queue entries. +4. Read the already-effective `pbjs.getConfig('userSync.userIds')` value, + normalize its supported array/config shape, preserve its non-`identityLink` + entries, append the managed entry, and apply that merged list synchronously + through the captured function. This covers publisher configuration that ran + after the external Prebid bundle loaded but before the deferred TSJS shim. + An absent or malformed effective list degrades to an empty publisher list. + Complete this step before processing any existing queue entries. 5. Call `pbjs.processQueue()`. Queued publisher `setConfig` calls flow through the wrapper, so a later queued `requestBids` observes the managed entry. 6. Keep the wrapper installed after queue processing so later publisher @@ -346,7 +351,7 @@ sequenceDiagram O->>TS: Configure integrations.prebid.liveramp TS-->>B: Inject liveRamp config and managed Prebid bundle - B->>B: Queue identityLink configuration + B->>B: Install setConfig guard and synchronously merge identityLink B->>LR: Prebid identityLink module resolves/refreshes envelope LR-->>B: Opaque RampID envelope B->>B: pbjs.getUserIdsAsEids() @@ -430,6 +435,10 @@ Add tests in `requestBids` call; - queued publisher `setConfig` followed by `requestBids` preserves other User ID entries while the auction observes the managed `identityLink` entry; +- User ID entries already effective before TSJS installation are preserved + while the managed `identityLink` entry is added; +- malformed pre-install `userSync.userIds` state degrades to the managed entry + without throwing; - a publisher `identityLink` update after `processQueue()` is normalized back to the operator-managed values; - repeated installation does not stack `setConfig` wrappers; From 0c93b3bf94a2845dea163683bacfdbfdc86f2c8f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:27:28 +0530 Subject: [PATCH 04/26] docs: plan LiveRamp integration implementation --- .../plans/2026-08-21-liveramp-integration.md | 902 ++++++++++++++++++ 1 file changed, 902 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-liveramp-integration.md diff --git a/docs/superpowers/plans/2026-08-21-liveramp-integration.md b/docs/superpowers/plans/2026-08-21-liveramp-integration.md new file mode 100644 index 000000000..0b85eb4cd --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-liveramp-integration.md @@ -0,0 +1,902 @@ +# LiveRamp Integration 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:** Make Trusted Server's existing Prebid RampID EID path first-class by adding validated operator configuration, deterministic `identityLink` setup, bundle diagnostics, tests, and documentation. + +**Architecture:** Add an optional typed `liveramp` subsection to `PrebidIntegrationConfig` and serialize it into the existing `window.__tsjs_prebid` bootstrap. The TSJS Prebid shim installs an idempotent `pbjs.setConfig` normalizer before `processQueue()`, synchronously merges the operator-owned `identityLink` entry with effective publisher User ID entries, and preserves all unrelated configuration. Existing `/auction`, OpenRTB `user.ext.eids`, `ts-eids`, consent, and EC/KV paths remain unchanged. + +**Tech Stack:** Rust 2024, Serde, validator, TypeScript, Prebid.js 10, Vitest, JSDOM, Vite, VitePress/Markdown, Cargo workspace aliases. + +**Specification:** `docs/superpowers/specs/2026-08-21-liveramp-integration-design.md` + +--- + +## File structure + +| File | Responsibility | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Define and validate LiveRamp operator settings; inject the browser-safe camel-cased config; host Rust unit tests. | +| `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Validate the injected shape, install the managed `identityLink` configuration guard, and preserve publisher User ID settings. | +| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Prove initial, queued, and late Prebid configuration behavior plus diagnostics and EID transport. | +| `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` | Characterize the existing `liveramp.com` → `identityLinkIdSystem` registry mapping. | +| `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` | Prove an external bundle can include and manifest `identityLinkIdSystem`. | +| `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` | Exercise the real generated Prebid bundle and served shim together with LiveRamp config. | +| `trusted-server.example.toml` | Show safe, commented operator configuration. | +| `docs/guide/integrations/prebid.md` | Explain LiveRamp prerequisites, configuration, lifecycle, degraded behavior, and verification. | +| `docs/guide/configuration.md` | Add the typed settings reference. | + +No new integration module, route, storage schema, cookie format, or upstream HTTP client is created. + +## Task 1: Add typed Rust configuration and head injection + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs:202` +- Test: `crates/trusted-server-core/src/integrations/prebid.rs:3028` +- Test: `crates/trusted-server-core/src/integrations/prebid.rs:4010` + +- [ ] **Step 1: Write failing configuration tests** + +Add focused tests beside the existing Prebid TOML parsing tests: + +```rust +#[test] +fn liveramp_config_parses_with_documented_defaults() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "999" +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert_eq!(liveramp.placement_id, "999", "should preserve placement ID"); + assert!(!liveramp.not_use_3p, "should allow cookie recognition by default"); + assert_eq!( + liveramp.storage_type, + PrebidLiveRampStorageType::Cookie, + "should default to cookie storage" + ); + assert_eq!(liveramp.expires_days, 15, "should default to conservative expiry"); + assert_eq!( + liveramp.refresh_in_seconds, 1800, + "should default to LiveRamp's recommended refresh" + ); +} + +#[test] +fn liveramp_config_accepts_explicit_supported_values() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "12345" +not_use_3p = true +storage_type = "html5" +expires_days = 30 +refresh_in_seconds = 3600 +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert!(liveramp.not_use_3p, "should preserve not_use_3p"); + assert_eq!(liveramp.storage_type, PrebidLiveRampStorageType::Html5); + assert_eq!(liveramp.expires_days, 30); + assert_eq!(liveramp.refresh_in_seconds, 3600); +} +``` + +Add table-driven rejection coverage using `parse_prebid_toml_result` for: + +- an otherwise valid `[integrations.prebid.liveramp]` subsection with + `placement_id` entirely absent; +- empty, whitespace-padded, and nonnumeric `placement_id`; +- `expires_days = 0` and `expires_days = 31`; +- `refresh_in_seconds = 0`; +- unknown `storage_type`; +- unknown fields within `[integrations.prebid.liveramp]`. + +Also assert that omitting the subsection leaves `config.liveramp == None`. + +- [ ] **Step 2: Run the focused Rust tests and verify they fail** + +Run: + +```bash +cargo test-fastly liveramp_config +``` + +Expected: compilation/test failure because `PrebidLiveRampConfig`, +`PrebidLiveRampStorageType`, and `PrebidIntegrationConfig::liveramp` do not yet +exist. + +- [ ] **Step 3: Implement the minimal typed settings** + +Add near `PrebidIntegrationConfig`: + +```rust +const fn default_liveramp_expires_days() -> u16 { + 15 +} + +const fn default_liveramp_refresh_in_seconds() -> u32 { + 1800 +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PrebidLiveRampStorageType { + #[default] + Cookie, + Html5, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PrebidLiveRampConfig { + #[validate(custom(function = "validate_liveramp_placement_id"))] + pub placement_id: String, + #[serde(default)] + pub not_use_3p: bool, + #[serde(default)] + pub storage_type: PrebidLiveRampStorageType, + #[serde(default = "default_liveramp_expires_days")] + #[validate(range(min = 1, max = 30))] + pub expires_days: u16, + #[serde(default = "default_liveramp_refresh_in_seconds")] + #[validate(range(min = 1))] + pub refresh_in_seconds: u32, +} +``` + +Implement `validate_liveramp_placement_id` using a `ValidationError` with a +stable message. Accept only a non-empty, already-trimmed ASCII-digit string. + +Add to `PrebidIntegrationConfig`: + +```rust +#[serde(default)] +#[validate(nested)] +pub liveramp: Option, +``` + +Update every direct `PrebidIntegrationConfig` initializer, especially +`base_config()`, with `liveramp: None`. + +- [ ] **Step 4: Run the focused configuration tests and verify they pass** + +Run: + +```bash +cargo test-fastly liveramp_config +``` + +Expected: all LiveRamp parsing/default/validation tests pass. + +- [ ] **Step 5: Write failing head-injection tests** + +Add tests beside the current head-injector tests: + +```rust +#[test] +fn head_injector_includes_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "999".to_string(), + not_use_3p: true, + storage_type: PrebidLiveRampStorageType::Html5, + expires_days: 30, + refresh_in_seconds: 3600, + }); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let script = &integration.head_inserts(&ctx)[0]; + assert!( + script.contains( + r#""liveRamp":{"placementId":"999","notUse3P":true,"storageType":"html5","expiresDays":30,"refreshInSeconds":3600}"# + ), + "should inject camel-cased LiveRamp config: {script}" + ); +} + +#[test] +fn head_injector_omits_liveramp_config_when_absent() { + // Build the normal context with base_config(). + // Assert the first insert does not contain `liveRamp`. +} + +#[test] +fn head_injector_escapes_script_breakout_in_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "1".to_string(), + ..valid_liveramp_config() + }); + + // Build the normal context. Assert the injected payload contains + // `1<\/script>")` + // has count 1: only the insert's legitimate outer closing tag remains. + // This test may build the invalid value directly because it exercises the + // serializer's defense in depth rather than TOML validation. +} +``` + +- [ ] **Step 6: Run both head-injection tests and verify they fail** + +Run: + +```bash +cargo test-fastly head_injector_includes_liveramp_config +cargo test-fastly head_injector_escapes_script_breakout_in_liveramp_config +``` + +Expected: both fail because the injected payload has no `liveRamp` property or +escaped LiveRamp Placement ID. + +- [ ] **Step 7: Inject a browser-specific serialization shape** + +Inside `IntegrationHeadInjector::head_inserts`, define a borrowed injected +shape so TOML remains snake_case while browser JSON is camelCase: + +```rust +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InjectedPrebidLiveRampConfig<'a> { + placement_id: &'a str, + not_use_3p: bool, + storage_type: PrebidLiveRampStorageType, + expires_days: u16, + refresh_in_seconds: u32, +} +``` + +Add a skipped-when-absent `live_ramp` field to +`InjectedPrebidClientConfig`, map `self.config.liveramp.as_ref()` into the +borrowed shape, and retain the existing ` { + const spec = getAdapterSpec() + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]) + + const request = spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidId: 'bid-1', + sizes: [[300, 250]], + bidder: 'trustedServer', + params: {}, + }, + ]) + + expect(JSON.parse(request.data).eids).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]) +}) +``` + +Add a logging assertion using a sentinel envelope and spies on `log.debug`, +`log.info`, `log.warn`, and `log.error`; no logged argument may contain the +sentinel. + +- [ ] **Step 4: Write the failing real-artifact test before implementation** + +Change the bundle built in `prebid-artifact-integration.test.mjs` to include +both `sharedIdSystem` and `identityLinkIdSystem`. Inject `liveRamp` before +evaluating the served shim, then assert after shim evaluation: + +```javascript +const configuredUserIds = pageWindow.pbjs.getConfig('userSync.userIds') +expect(configuredUserIds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + storage: expect.objectContaining({ name: 'idl_env' }), + }), + ]) +) +``` + +Retain the current real `/auction` request assertion. Network remains stubbed; +the test must not contact LiveRamp. + +- [ ] **Step 5: Run the focused unit and artifact suites and verify failures** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts +npx vitest run test/prebid-artifact-integration.test.mjs +``` + +Expected: LiveRamp ownership tests fail because the injected shape and +`setConfig` normalizer do not exist, and the artifact test fails because no +managed entry is installed. The transport-only characterizations may already +pass; retain them as evidence of the pre-existing path. + +- [ ] **Step 6: Add the injected TypeScript types and constants** + +Add: + +```typescript +interface InjectedLiveRampConfig { + placementId: string + notUse3P: boolean + storageType: 'cookie' | 'html5' + expiresDays: number + refreshInSeconds: number +} + +interface InjectedPrebidConfig { + // Existing fields omitted. + liveRamp?: InjectedLiveRampConfig +} + +const IDENTITY_LINK_CONFIG_NAME = 'identityLink' +const IDENTITY_LINK_STORAGE_NAME = 'idl_env' +const LIVE_RAMP_SET_CONFIG_SENTINEL = '__tsLiveRampSetConfigInstalled' +``` + +Keep this internal to the Prebid module; do not add a new global API. + +- [ ] **Step 7: Extract reusable User ID list parsing** + +Refactor the shape handling currently embedded in +`configuredUserIdNamesFromConfig` into a helper that returns validated entry +objects from any of these inputs: + +- the direct array returned by `getConfig('userSync.userIds')`; +- `{ userSync: { userIds: [...] } }`; +- `{ userIds: [...] }`. + +Use explicit record and entry guards. A valid entry is a non-array object with +a non-empty string `name`; filter malformed members rather than forwarding +them. The parser returns an empty array for malformed containers. Keep a +separate `hasUserIdsPath(config)` predicate equivalent to: + +```typescript +function hasUserIdsPath(config: unknown): config is { + userSync: Record & { userIds: unknown } +} { + return ( + isRecord(config) && + isRecord(config.userSync) && + Object.prototype.hasOwnProperty.call(config.userSync, 'userIds') + ) +} +``` + +This distinction is required: an absent path passes through unchanged, while +an explicitly empty `userIds: []` is normalized to the managed entry. Update +`configuredUserIdNamesFromConfig` to derive names from that helper so +diagnostics and LiveRamp normalization agree on supported shapes. + +- [ ] **Step 8: Implement the managed entry and normalizer** + +Implement small focused helpers equivalent to: + +```typescript +function liveRampUserId( + config: InjectedLiveRampConfig +): Record { + return { + name: IDENTITY_LINK_CONFIG_NAME, + params: { pid: config.placementId, notUse3P: config.notUse3P }, + storage: { + type: config.storageType, + name: IDENTITY_LINK_STORAGE_NAME, + expires: config.expiresDays, + refreshInSeconds: config.refreshInSeconds, + }, + } +} + +function withManagedLiveRampUserId( + config: Record, + managedEntry: Record +): Record { + if (!hasUserIdsPath(config)) return config + + const retained = configuredUserIdEntries(config.userSync.userIds).filter( + (entry) => entry.name !== IDENTITY_LINK_CONFIG_NAME + ) + return { + ...config, + userSync: { + ...config.userSync, + userIds: [...retained, managedEntry], + }, + } +} +``` + +`configuredUserIdEntries` must support the three shapes from Step 7 and return +fresh arrays. The spread operations preserve top-level properties and sibling +`userSync` properties. Do not mutate publisher-owned arrays or objects in +place. + +- [ ] **Step 9: Install the idempotent `setConfig` guard before queue processing** + +In `installPrebidNpm`, after confirming the real Prebid API and before the +existing base configuration and `processQueue()` call: + +1. If injected `liveRamp` is absent, do nothing. +2. Capture and bind the current `pbjs.setConfig`. +3. Replace it with a wrapper that normalizes only calls containing + `userSync.userIds`; pass all other calls through unchanged. +4. Mark the Prebid object with the sentinel so installation cannot stack. +5. Read effective User ID entries through `pbjs.getConfig`. +6. Call the wrapper synchronously with the effective list, producing one + managed entry before any queued auction. +7. Leave the wrapper installed across `processQueue()` and later calls. + +Use logic equivalent to: + +```typescript +const managedPbjs = pbjs as typeof pbjs & Record +if (managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { + const originalSetConfig = pbjs.setConfig.bind(pbjs) + const managedEntry = liveRampUserId(config.liveRamp) + + pbjs.setConfig = (publisherConfig) => { + let nextConfig = publisherConfig + try { + if (hasUserIdsPath(publisherConfig)) { + nextConfig = withManagedLiveRampUserId(publisherConfig, managedEntry) + } + } catch { + log.error('Prebid LiveRamp configuration could not be normalized') + } + return originalSetConfig(nextConfig) + } + managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] = true + + const effective = configuredUserIdEntries(pbjs.getConfig('userSync.userIds')) + pbjs.setConfig({ userSync: { userIds: effective } }) +} +``` + +Adapt the callback and return types to the repository's actual `pbjs` typing. +The original method is invoked exactly once, its return value is preserved, +and normalization errors never log values. The sentinel lives on `pbjs`, not +on the page-level shim state: a test must deliberately reset only +`__tsjsPrebidShimInstalled`, reinstall, and prove the wrapper reference is +unchanged and publisher calls are normalized once. + +- [ ] **Step 10: Run focused unit and artifact tests and make them pass** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts +npx vitest run test/prebid-artifact-integration.test.mjs +``` + +Expected: unit tests pass; the real bundle advertises both User ID modules, the +shim configures one `identityLink` entry, and the controlled auction still +reaches `/auction`. + +- [ ] **Step 11: Format, lint, and commit Task 2** + +Run: + +```bash +cd crates/trusted-server-js/lib +npm run format +npm run lint +git add src/integrations/prebid/index.ts test/integrations/prebid/index.test.ts test/prebid-artifact-integration.test.mjs +git commit -m "feat: manage LiveRamp identityLink configuration" +``` + +## Task 3: Lock bundle, transport, consent, and EC behavior with regression tests + +**Files:** + +- Test: `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` +- Test: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` +- Test: `crates/trusted-server-core/src/auction/endpoints.rs` +- Test: `crates/trusted-server-core/src/consent/mod.rs` +- Test: `crates/trusted-server-core/src/ec/prebid_eids.rs` + +- [ ] **Step 1: Add the explicit registry mapping test** + +```typescript +it('maps LiveRamp EIDs to identityLinkIdSystem', () => { + expect( + resolvePrebidUserIdModulesFromEids([ + { source: 'liveramp.com', uids: [{ id: 'opaque-envelope', atype: 3 }] }, + ]) + ).toEqual({ + modules: ['userId', 'identityLinkIdSystem'], + missingSources: [], + }) +}) +``` + +Also load the checked-in registry JSON or expose a narrow helper and assert the +default preset contains `identityLinkIdSystem`. Do not duplicate the registry +as a second production constant. + +- [ ] **Step 2: Add a bundle manifest test for `identityLinkIdSystem`** + +Extend the existing `includes generated User ID metadata` case or add a focused +case that invokes: + +```javascript +await main([ + '--adapters', + 'rubicon', + '--user-id-modules', + 'identityLinkIdSystem', + '--out', + outputDirectory, +]) +``` + +Assert the manifest's `userIdModules` is exactly +`['identityLinkIdSystem']` and the generated bundle contains the module name. + +- [ ] **Step 3: Run the two characterization suites** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run \ + test/integrations/prebid/user_id_modules.test.ts \ + test/build-prebid-external.test.mjs +``` + +Expected: tests pass using the existing registry and generator. If they fail, +fix the single checked-in registry/generator source rather than introducing a +LiveRamp-only bundle path. + +- [ ] **Step 4: Add or rename LiveRamp-specific Rust regression fixtures** + +Add focused tests (or rename/extend an existing generic fixture while keeping +its broader assertions) proving: + +- in `auction/endpoints.rs`, a client `liveramp.com` UID equal to the resolved + KV UID is merged once, with server-resolved metadata winning on conflict; +- in `consent/mod.rs`, a `liveramp.com` EID is removed when consent denies + identity forwarding; +- in `ec/prebid_eids.rs`, a structured `ts-eids` cookie containing an opaque + `liveramp.com` envelope writes that exact opaque string once to a registry + partner whose source domain is `liveramp.com`. + +Use only synthetic values such as `opaque-test-envelope`. Assert that tests do +not decode or inspect an envelope's contents. + +- [ ] **Step 5: Run the LiveRamp Rust regression fixtures** + +Run from the repository root: + +```bash +cargo test-fastly liveramp +``` + +Expected: forwarding, merge/deduplication, consent removal, and later-request +EC ingestion fixtures all pass. + +- [ ] **Step 6: Commit Task 3** + +Run: + +```bash +git add crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts \ + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs \ + crates/trusted-server-core/src/auction/endpoints.rs \ + crates/trusted-server-core/src/consent/mod.rs \ + crates/trusted-server-core/src/ec/prebid_eids.rs +git commit -m "test: cover LiveRamp Prebid bundle support" +``` + +## Task 4: Document configuration, lifecycle, and operational validation + +**Files:** + +- Modify: `trusted-server.example.toml:41` +- Modify: `docs/guide/integrations/prebid.md:50` +- Modify: `docs/guide/integrations/prebid.md:419` +- Modify: `docs/guide/configuration.md:1050` + +- [ ] **Step 1: Add the commented example configuration** + +Add beneath the Prebid bundle configuration in `trusted-server.example.toml`: + +```toml +# Optional managed LiveRamp RampID configuration. The external Prebid bundle +# must contain identityLinkIdSystem. Obtain the Placement ID and approve the +# publisher origin with LiveRamp before enabling. +# [integrations.prebid.liveramp] +# placement_id = "999" +# not_use_3p = false +# storage_type = "cookie" +# expires_days = 15 +# refresh_in_seconds = 1800 +``` + +Do not add a real Placement ID or credential. + +- [ ] **Step 2: Update the configuration reference** + +Add `[integrations.prebid.liveramp]` fields to both Prebid option tables: + +| Field | Type | Default | Description | +| ----------------------------- | ------------------- | ------------------------------- | ------------------------------------------------------------ | +| `liveramp.placement_id` | String | Required when subsection exists | Numeric LiveRamp Placement ID for `identityLink`. | +| `liveramp.not_use_3p` | Boolean | `false` | Disable cookie-recognized RampID envelopes when true. | +| `liveramp.storage_type` | `cookie` or `html5` | `cookie` | Browser storage used by the Prebid module. | +| `liveramp.expires_days` | Integer 1–30 | `15` | Envelope storage lifetime in days. | +| `liveramp.refresh_in_seconds` | Positive integer | `1800` | Interval before retrieving a potentially refreshed envelope. | + +State that storage name `idl_env` is fixed by the integration. + +- [ ] **Step 3: Add the LiveRamp guide section** + +In `docs/guide/integrations/prebid.md`, document: + +- prerequisites: Placement ID, approved origin, CMP/LiveRamp consent posture, + and an external bundle containing `identityLinkIdSystem`; +- the exact TOML example and `ts prebid bundle` selection; +- operator ownership of the single `identityLink` entry while preserving other + User ID modules; +- asynchronous resolution: a new browser's first auction may have no RampID; +- the existing flow through `getUserIdsAsEids()`, `/auction`, + `user.ext.eids`, `ts-eids`, and EC/KV; +- degraded behavior for no consent, no recognition, missing module, LiveRamp + network failure, and KV failure; +- privacy guidance: envelopes are opaque and must not be logged; +- the explicit product boundary: this forwards RampID EIDs, not ATS Direct + audience segments; +- a credential-based manual validation checklist matching Section 11.5 of the + design spec, recording only booleans, counts, source names, and status codes. + +- [ ] **Step 4: Format and verify docs** + +Run: + +```bash +cd docs +npm run format +``` + +Expected: all documentation and TOML examples satisfy Prettier checks. + +- [ ] **Step 5: Commit Task 4** + +Run: + +```bash +git add trusted-server.example.toml docs/guide/integrations/prebid.md docs/guide/configuration.md +git commit -m "docs: explain managed LiveRamp RampID setup" +``` + +## Task 5: Run full verification and prepare live validation handoff + +**Files:** + +- Verify: all files changed in Tasks 1–4 +- Reference: `docs/superpowers/specs/2026-08-21-liveramp-integration-design.md` + +- [ ] **Step 1: Run the complete TSJS test and build gates** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run +npm run format +npm run lint +node build-all.mjs +``` + +Expected: all commands exit 0. + +- [ ] **Step 2: Run Rust formatting and adapter test gates** + +From the repository root, run: + +```bash +cargo fmt --all -- --check +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` + +Expected: all commands exit 0. Do not substitute bare +`cargo test --workspace`. + +- [ ] **Step 3: Run all target-matched clippy gates** + +Run: + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: all commands exit 0 with warnings denied. + +- [ ] **Step 4: Re-run documentation formatting and inspect the final diff** + +Run: + +```bash +cd docs +npm run format +cd .. +git diff --check +git status --short +git diff main...HEAD --stat +``` + +Expected: formatting and diff checks pass; status contains only intentional +plan/implementation changes. + +- [ ] **Step 5: Record credential-gated validation status** + +If LiveRamp test configuration is available, execute the guide's manual +validation on an approved non-production origin and report only: + +- approved origin used (domain, not credentials); +- whether `idl_env` was created/refreshed; +- whether `getUserIdsAsEids()` exposed source `liveramp.com`; +- whether the controlled PBS request contained that source; +- whether a later request ingested the EID into the configured + `liveramp.com` EC partner; +- whether opt-out removed it; and +- whether an unapproved origin degraded to no LiveRamp EID without blocking + the auction; and +- status codes/counts without envelope values. + +If credentials remain unavailable, report exactly: “Code complete; live +LiveRamp validation pending IABTechLab/uid2-optout#385.” Do not block automated +verification or add fake live-success evidence. + +In either case, prepare the explicit parent-epic acceptance handoff: “RampID +identity envelopes traverse the existing Prebid auction path; ATS Direct +audience segments are not passed by this implementation.” + +- [ ] **Step 6: Commit any verification-only corrections** + +Only if verification required source changes, repeat the affected focused and +full gates, then commit the minimal correction: + +```bash +git add +git commit -m "fix: address LiveRamp verification findings" +``` + +Do not create an empty verification commit. From bf213ff7da88ba98520bb563a50eb731b0e9cebe Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:33:37 +0530 Subject: [PATCH 05/26] Configure LiveRamp for managed Prebid --- .../src/integrations/prebid.rs | 313 +++++++++++++++++- 1 file changed, 305 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 4cc10f8da..4c76981bd 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -198,6 +198,62 @@ fn extract_prebid_error_message( #[cfg(test)] const GPC_US_PRIVACY: &str = "1YYN"; +const fn default_liveramp_expires_days() -> u16 { + 15 +} + +const fn default_liveramp_refresh_in_seconds() -> u32 { + 1800 +} + +fn validate_liveramp_placement_id(value: &str) -> Result<(), ValidationError> { + if !value.is_empty() && value.trim() == value && value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Ok(()); + } + + let mut error = ValidationError::new("invalid_liveramp_placement_id"); + error.message = Some( + "LiveRamp placement_id must be a non-empty ASCII-digit string without surrounding whitespace" + .into(), + ); + Err(error) +} + +/// Browser storage mechanism used by Prebid's LiveRamp IdentityLink module. +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PrebidLiveRampStorageType { + /// Store the opaque RampID envelope in a browser cookie. + #[default] + Cookie, + /// Store the opaque RampID envelope in browser local storage. + Html5, +} + +/// Operator-owned configuration for Prebid's LiveRamp IdentityLink module. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PrebidLiveRampConfig { + /// Numeric Placement ID assigned by LiveRamp for the approved publisher origin. + #[validate(custom(function = "validate_liveramp_placement_id"))] + pub placement_id: String, + /// Disable third-party-cookie recognition when `true`. + #[serde(default)] + pub not_use_3p: bool, + /// Browser storage mechanism for the opaque RampID envelope. + #[serde(default)] + pub storage_type: PrebidLiveRampStorageType, + /// Number of days for which the browser stores the opaque envelope. + #[serde(default = "default_liveramp_expires_days")] + #[validate(range(min = 1, max = 30))] + pub expires_days: u16, + /// Number of seconds before Prebid may refresh the opaque envelope. + #[serde(default = "default_liveramp_refresh_in_seconds")] + #[validate(range(min = 1))] + pub refresh_in_seconds: u32, +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { #[serde(default = "default_enabled")] @@ -209,6 +265,10 @@ pub struct PrebidIntegrationConfig { /// it in JavaScript. #[serde(default)] pub account_id: Option, + /// Optional managed LiveRamp RampID configuration for Prebid.js. + #[serde(default)] + #[validate(nested)] + pub liveramp: Option, #[serde(default = "default_timeout_ms")] pub timeout_ms: u32, #[serde( @@ -1075,10 +1135,23 @@ impl IntegrationHeadInjector for PrebidIntegration { } fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct InjectedPrebidLiveRampConfig<'a> { + placement_id: &'a str, + #[serde(rename = "notUse3P")] + not_use_3p: bool, + storage_type: PrebidLiveRampStorageType, + expires_days: u16, + refresh_in_seconds: u32, + } + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct InjectedPrebidClientConfig<'a> { account_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + live_ramp: Option>, timeout: u32, debug: bool, bidders: &'a [String], @@ -1088,14 +1161,24 @@ impl IntegrationHeadInjector for PrebidIntegration { excluded_gam_ad_unit_path_suffixes: &'a [String], } - let payload = InjectedPrebidClientConfig { - account_id: self.config.account_id.as_deref().unwrap_or_default(), - timeout: self.config.timeout_ms, - debug: self.config.debug, - bidders: &self.config.bidders, - client_side_bidders: &self.config.client_side_bidders, - excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes, - }; + let payload = + InjectedPrebidClientConfig { + account_id: self.config.account_id.as_deref().unwrap_or_default(), + live_ramp: self.config.liveramp.as_ref().map(|config| { + InjectedPrebidLiveRampConfig { + placement_id: &config.placement_id, + not_use_3p: config.not_use_3p, + storage_type: config.storage_type, + expires_days: config.expires_days, + refresh_in_seconds: config.refresh_in_seconds, + } + }), + timeout: self.config.timeout_ms, + debug: self.config.debug, + bidders: &self.config.bidders, + client_side_bidders: &self.config.client_side_bidders, + excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes, + }; // Escape ` PrebidLiveRampConfig { + PrebidLiveRampConfig { + placement_id: "999".to_string(), + not_use_3p: false, + storage_type: PrebidLiveRampStorageType::Cookie, + expires_days: 15, + refresh_in_seconds: 1800, + } + } + struct PredictOnlyBackend; impl PlatformBackend for PredictOnlyBackend { @@ -3119,6 +3213,129 @@ server_url = "https://prebid.example/openrtb2/auction" ); } + #[test] + fn liveramp_config_parses_with_documented_defaults() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "999" +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert_eq!(liveramp.placement_id, "999", "should preserve placement ID"); + assert!( + !liveramp.not_use_3p, + "should allow cookie recognition by default" + ); + assert_eq!( + liveramp.storage_type, + PrebidLiveRampStorageType::Cookie, + "should default to cookie storage" + ); + assert_eq!( + liveramp.expires_days, 15, + "should default to conservative expiry" + ); + assert_eq!( + liveramp.refresh_in_seconds, 1800, + "should default to LiveRamp's recommended refresh" + ); + } + + #[test] + fn liveramp_config_accepts_explicit_supported_values() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "12345" +not_use_3p = true +storage_type = "html5" +expires_days = 30 +refresh_in_seconds = 3600 +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert!(liveramp.not_use_3p, "should preserve not_use_3p"); + assert_eq!( + liveramp.storage_type, + PrebidLiveRampStorageType::Html5, + "should preserve HTML5 storage" + ); + assert_eq!( + liveramp.expires_days, 30, + "should preserve configured expiry" + ); + assert_eq!( + liveramp.refresh_in_seconds, 3600, + "should preserve configured refresh interval" + ); + } + + #[test] + fn liveramp_config_rejects_invalid_values() { + for (name, live_ramp_section) in [ + ("missing placement ID", "not_use_3p = true"), + ("empty placement ID", "placement_id = \"\""), + ("padded placement ID", "placement_id = \" 999 \""), + ( + "nonnumeric placement ID", + "placement_id = \"placement-999\"", + ), + ("zero expiry", "placement_id = \"999\"\nexpires_days = 0"), + ( + "expiry above limit", + "placement_id = \"999\"\nexpires_days = 31", + ), + ( + "zero refresh", + "placement_id = \"999\"\nrefresh_in_seconds = 0", + ), + ( + "unknown storage", + "placement_id = \"999\"\nstorage_type = \"session\"", + ), + ( + "unknown field", + "placement_id = \"999\"\nunsupported = true", + ), + ] { + let result = parse_prebid_toml_result(&format!( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +{live_ramp_section} +"# + )); + + assert!(result.is_err(), "should reject {name}"); + } + } + + #[test] + fn liveramp_config_is_optional() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" +"#, + ); + + assert!( + config.liveramp.is_none(), + "should omit LiveRamp configuration by default" + ); + } + #[test] fn excluded_gam_ad_unit_path_suffixes_reject_invalid_values() { for (suffix, expected_message) in [ @@ -4017,6 +4234,86 @@ external_bundle_sri = "sha384-AAAA" ); } + #[test] + fn head_injector_includes_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "999".to_string(), + not_use_3p: true, + storage_type: PrebidLiveRampStorageType::Html5, + expires_days: 30, + refresh_in_seconds: 3600, + }); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + let script = &inserts[0]; + + assert!( + script.contains( + r#""liveRamp":{"placementId":"999","notUse3P":true,"storageType":"html5","expiresDays":30,"refreshInSeconds":3600}"# + ), + "should inject camel-cased LiveRamp config: {script}" + ); + } + + #[test] + fn head_injector_omits_liveramp_config_when_absent() { + let integration = PrebidIntegration::new(base_config()); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + let script = &inserts[0]; + + assert!( + !script.contains("liveRamp"), + "should omit LiveRamp config when absent: {script}" + ); + } + + #[test] + fn head_injector_escapes_script_breakout_in_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "1".to_string(), + ..valid_liveramp_config() + }); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + let script = &inserts[0]; + + assert!( + script.contains(r#""placementId":"1<\/script>").count(), + 1, + "should contain only the legitimate outer closing script tag" + ); + } + #[test] fn head_injector_includes_excluded_gam_ad_unit_path_suffixes() { let mut config = base_config(); From 73f75d9adf1b8ddd10df904e5d6e87b805594b8f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:39:34 +0530 Subject: [PATCH 06/26] Manage LiveRamp IdentityLink configuration --- .../lib/src/integrations/prebid/index.ts | 126 ++++++-- .../test/integrations/prebid/index.test.ts | 291 ++++++++++++++++++ .../test/prebid-artifact-integration.test.mjs | 28 +- 3 files changed, 422 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 44b47f2da..4678d35ad 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -131,6 +131,9 @@ const TS_REFRESH_TARGETING_KEYS = [ const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; const MAX_PENDING_PUBLISHER_BIDS = 2048; const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000; +const IDENTITY_LINK_CONFIG_NAME = 'identityLink'; +const IDENTITY_LINK_STORAGE_NAME = 'idl_env'; +const LIVE_RAMP_SET_CONFIG_SENTINEL = '__tsLiveRampSetConfigInstalled'; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -155,8 +158,20 @@ interface InjectedPrebidConfig { clientSideBidders?: string[]; /** GAM ad-unit-path suffixes excluded from refresh auctions. */ excludedGamAdUnitPathSuffixes?: string[]; + /** Operator-owned LiveRamp IdentityLink configuration. */ + liveRamp?: InjectedLiveRampConfig; } +interface InjectedLiveRampConfig { + placementId: string; + notUse3P: boolean; + storageType: 'cookie' | 'html5'; + expiresDays: number; + refreshInSeconds: number; +} + +type PrebidUserIdConfigEntry = Record & { name: string }; + interface PrebidUserIdDiagnostics { includedModules: string[]; configuredUserIdNames: string[]; @@ -187,29 +202,74 @@ export function collectBidders(adUnits: Array<{ bids?: Array<{ bidder?: string } return [...bidders]; } -function configuredUserIdNamesFromConfig(config: unknown): string[] { - const userIds = Array.isArray(config) - ? config - : config && typeof config === 'object' - ? (( - config as { - userSync?: { userIds?: Array<{ name?: unknown }> }; - userIds?: Array<{ name?: unknown }>; - } - ).userSync?.userIds ?? (config as { userIds?: Array<{ name?: unknown }> }).userIds) - : undefined; +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} - if (!Array.isArray(userIds)) { - return []; +function configuredUserIdEntries(config: unknown): PrebidUserIdConfigEntry[] { + let userIds: unknown; + if (Array.isArray(config)) { + userIds = config; + } else if (isRecord(config)) { + userIds = isRecord(config.userSync) ? config.userSync.userIds : undefined; + if (!Array.isArray(userIds)) { + userIds = config.userIds; + } } - return [ - ...new Set( - userIds - .map((entry) => entry?.name) - .filter((name): name is string => typeof name === 'string' && name.length > 0) - ), - ].sort(); + if (!Array.isArray(userIds)) return []; + + return userIds.filter( + (entry): entry is PrebidUserIdConfigEntry => + isRecord(entry) && typeof entry.name === 'string' && entry.name.length > 0 + ); +} + +function hasUserIdsPath(config: unknown): config is Record & { + userSync: Record & { userIds: unknown }; +} { + return ( + isRecord(config) && + isRecord(config.userSync) && + Object.prototype.hasOwnProperty.call(config.userSync, 'userIds') + ); +} + +function configuredUserIdNamesFromConfig(config: unknown): string[] { + const userIds = configuredUserIdEntries(config); + + return [...new Set(userIds.map((entry) => entry.name))].sort(); +} + +function liveRampUserId(config: InjectedLiveRampConfig): PrebidUserIdConfigEntry { + return { + name: IDENTITY_LINK_CONFIG_NAME, + params: { pid: config.placementId, notUse3P: config.notUse3P }, + storage: { + type: config.storageType, + name: IDENTITY_LINK_STORAGE_NAME, + expires: config.expiresDays, + refreshInSeconds: config.refreshInSeconds, + }, + }; +} + +function withManagedLiveRampUserId( + config: Record, + managedEntry: PrebidUserIdConfigEntry +): Record { + if (!hasUserIdsPath(config)) return config; + + const retained = configuredUserIdEntries(config.userSync.userIds).filter( + (entry) => entry.name !== IDENTITY_LINK_CONFIG_NAME + ); + return { + ...config, + userSync: { + ...config.userSync, + userIds: [...retained, managedEntry], + }, + }; } function readConfiguredUserIdNames(): string[] { @@ -1106,6 +1166,32 @@ export function installPrebidNpm(config?: Partial): typeof pbjs debug: config?.debug ?? injected?.debug, }; + const managedPbjs = pbjs as typeof pbjs & Record; + if (injected?.liveRamp && managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { + const originalSetConfig = pbjs.setConfig.bind(pbjs); + const managedEntry = liveRampUserId(injected.liveRamp); + + pbjs.setConfig = ((publisherConfig: PbjsConfig) => { + let nextConfig = publisherConfig; + try { + if (hasUserIdsPath(publisherConfig)) { + nextConfig = withManagedLiveRampUserId(publisherConfig, managedEntry) as PbjsConfig; + } + } catch { + log.error('[tsjs-prebid] LiveRamp configuration could not be normalized'); + } + return originalSetConfig(nextConfig); + }) as typeof pbjs.setConfig; + managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] = true; + + const getConfig = (pbjs as unknown as { getConfig?: (key?: string) => unknown }).getConfig; + const effectiveUserIds = + typeof getConfig === 'function' + ? configuredUserIdEntries(getConfig.call(pbjs, 'userSync.userIds')) + : []; + pbjs.setConfig({ userSync: { userIds: effectiveUserIds } } as PbjsConfig); + } + auctionEndpoint = merged.endpoint ?? '/auction'; const apsRendererSupported = hasApsRendererApi(); if (apsRendererSupported) { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 8ead01aa8..e33cf68ae 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -27,6 +27,25 @@ const DEFAULT_BUNDLE_MANIFEST = { userIdModules: ['sharedIdSystem'], }; +const LIVE_RAMP_CONFIG = { + placementId: '999', + notUse3P: false, + storageType: 'cookie' as const, + expiresDays: 15, + refreshInSeconds: 1800, +}; + +const EXPECTED_IDENTITY_LINK = { + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + storage: { + type: 'cookie', + name: 'idl_env', + expires: 15, + refreshInSeconds: 1800, + }, +}; + /** Loose bid shape used by the requestBids shim tests. */ interface TestBid { bidder: string; @@ -47,6 +66,7 @@ interface InjectedPrebidTestConfig { bidders?: string[]; clientSideBidders?: string[]; excludedGamAdUnitPathSuffixes?: unknown; + liveRamp?: typeof LIVE_RAMP_CONFIG; } interface TestGoogletag { @@ -210,6 +230,9 @@ import envelope from '../../fixtures/aps-renderer-v1.json'; // self-init above already set it), so every test starts from a clean page. beforeEach(() => { delete testWindow.__tsjsPrebidShimInstalled; + mockPbjs.setConfig = mockSetConfig; + mockPbjs.processQueue = mockProcessQueue; + delete mockPbjs['__tsLiveRampSetConfigInstalled']; }); describe('prebid/collectBidders', () => { @@ -403,9 +426,14 @@ describe('prebid/auctionBidsToPrebidBids', () => { describe('prebid/installPrebidNpm', () => { beforeEach(() => { vi.clearAllMocks(); + mockSetConfig.mockReset(); + mockProcessQueue.mockReset(); // Reset requestBids to the mock so each test starts fresh mockPbjs.requestBids = mockRequestBids; + mockPbjs.setConfig = mockSetConfig; + mockPbjs.processQueue = mockProcessQueue; mockPbjs.adUnits = []; + mockPbjs.que = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); mockGetConfig.mockReset(); @@ -414,6 +442,7 @@ describe('prebid/installPrebidNpm', () => { delete testWindow.__tsjs_prebid_diagnostics; delete testWindow.tsjs; delete mockPbjs['__tsApsBidResponseListenerInstalled']; + delete mockPbjs['__tsLiveRampSetConfigInstalled']; delete mockPbjs.bidderSettings; }); @@ -785,6 +814,158 @@ describe('prebid/installPrebidNpm', () => { expect(mockProcessQueue).toHaveBeenCalledTimes(1); }); + it('leaves setConfig unchanged when LiveRamp is not configured', () => { + const originalSetConfig = mockPbjs.setConfig; + + installPrebidNpm(); + + expect(mockPbjs.setConfig).toBe(originalSetConfig); + expect(mockSetConfig.mock.calls.some(([value]) => value?.userSync?.userIds)).toBe(false); + }); + + it('preserves effective User ID entries and replaces identityLink exactly once', () => { + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' + ? [ + { name: 'sharedId', storage: { name: '_sharedid' } }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + { name: 'identityLink', params: { pid: 'duplicate-value' } }, + ] + : {} + ); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + + installPrebidNpm(); + + const managedCall = mockSetConfig.mock.calls.find(([value]) => value?.userSync?.userIds); + expect(managedCall?.[0]).toEqual({ + userSync: { + userIds: [{ name: 'sharedId', storage: { name: '_sharedid' } }, EXPECTED_IDENTITY_LINK], + }, + }); + expect( + managedCall?.[0].userSync.userIds.filter( + (entry: { name?: string }) => entry.name === 'identityLink' + ) + ).toHaveLength(1); + }); + + it('drops malformed effective User ID state and installs the managed entry', () => { + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [null, 'invalid', {}, { name: '' }, { name: 'sharedId' }] : {} + ); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + + expect(() => installPrebidNpm()).not.toThrow(); + + const managedCall = mockSetConfig.mock.calls.find(([value]) => value?.userSync?.userIds); + expect(managedCall?.[0].userSync.userIds).toEqual([ + { name: 'sharedId' }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('installs the managed entry before processing the publisher queue', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + + installPrebidNpm(); + + const managedCallOrder = mockSetConfig.mock.invocationCallOrder.find( + (_, index) => mockSetConfig.mock.calls[index][0]?.userSync?.userIds + ); + expect(managedCallOrder).toBeLessThan(mockProcessQueue.mock.invocationCallOrder[0]); + }); + + it('normalizes queued User ID config before a queued auction observes it', () => { + let observedUserIds: unknown; + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + mockPbjs.que = [ + () => + mockPbjs.setConfig({ + userSync: { + syncDelay: 50, + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + auctionOptions: { suppressStaleRender: true }, + }), + () => { + observedUserIds = mockSetConfig.mock.calls.at(-1)?.[0]?.userSync?.userIds; + }, + ]; + mockProcessQueue.mockImplementation(() => { + for (const callback of mockPbjs.que.splice(0)) callback(); + }); + + installPrebidNpm(); + + expect(observedUserIds).toEqual([{ name: 'sharedId' }, EXPECTED_IDENTITY_LINK]); + const queuedConfig = mockSetConfig.mock.calls.at(-1)?.[0]; + expect(queuedConfig.userSync.syncDelay).toBe(50); + expect(queuedConfig.auctionOptions).toEqual({ suppressStaleRender: true }); + }); + + it('normalizes publisher identityLink updates after processQueue', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + + mockPbjs.setConfig({ + userSync: { + userIds: [ + { name: 'id5Id', params: { partner: 1 } }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }); + + expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'id5Id', params: { partner: 1 } }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('passes unrelated publisher configuration through by reference', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + const publisherConfig = { priceGranularity: 'medium', userSync: { syncDelay: 50 } }; + + mockPbjs.setConfig(publisherConfig); + + expect(mockSetConfig.mock.calls.at(-1)?.[0]).toBe(publisherConfig); + }); + + it('does not stack the LiveRamp setConfig wrapper across shim reinstallations', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + const managedSetConfig = mockPbjs.setConfig; + delete testWindow.__tsjsPrebidShimInstalled; + + installPrebidNpm(); + + expect(mockPbjs.setConfig).toBe(managedSetConfig); + mockPbjs.setConfig({ userSync: { userIds: [] } }); + expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([EXPECTED_IDENTITY_LINK]); + }); + + it('reports identityLink missing when its bundle module is absent', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + testWindow.__tsjs_prebid_bundle = { userIdModules: [] }; + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [EXPECTED_IDENTITY_LINK] : {} + ); + + installPrebidNpm(); + + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: [], + configuredUserIdNames: ['identityLink'], + missingConfiguredUserIdNames: ['identityLink'], + }); + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + it('reports the User ID modules selected by the generated bundle', () => { installPrebidNpm(); @@ -935,6 +1116,90 @@ describe('prebid/installPrebidNpm', () => { ]); }); + it('forwards the opaque LiveRamp envelope as a liveramp.com EID', () => { + const spec = getAdapterSpec(); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + + const request = spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidId: 'bid-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]); + + expect(JSON.parse(request.data).eids).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + }); + + it('drops empty and malformed LiveRamp envelope values', () => { + const spec = getAdapterSpec(); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [ + { id: '' }, + { id: undefined as unknown as string }, + { id: 'opaque-valid-envelope', atype: 3 }, + ], + }, + { source: '', uids: [{ id: 'opaque-invalid-source' }] }, + ]); + + const request = spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]); + + expect(JSON.parse(request.data).eids).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-valid-envelope', atype: 3 }], + }, + ]); + }); + + it('never writes an opaque LiveRamp envelope to logs', () => { + const sentinel = 'opaque-envelope-must-not-be-logged'; + const spies = [ + vi.spyOn(log, 'debug').mockImplementation(() => {}), + vi.spyOn(log, 'info').mockImplementation(() => {}), + vi.spyOn(log, 'warn').mockImplementation(() => {}), + vi.spyOn(log, 'error').mockImplementation(() => {}), + ]; + const spec = getAdapterSpec(); + mockGetUserIdsAsEids.mockReturnValue([ + { source: 'liveramp.com', uids: [{ id: sentinel, atype: 3 }] }, + ]); + + spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]); + + const logged = spies.flatMap((spy) => spy.mock.calls).flat(); + expect(logged.some((value) => JSON.stringify(value).includes(sentinel))).toBe(false); + }); + it('buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent', () => { const spec = getAdapterSpec(); document.cookie = 'ts-eids=stale-value'; @@ -1389,6 +1654,32 @@ describe('prebid/installPrebidNpm', () => { ]); }); + it('preserves an opaque LiveRamp envelope in the ts-eids cookie', () => { + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + + const pbjs = installPrebidNpm(); + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg); + + const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; + expect(cookieValue).toBeDefined(); + expect(JSON.parse(atob(cookieValue!))).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + }); + it('clears ts-eids cookie after bidsBackHandler when no current EIDs remain', () => { document.cookie = `ts-eids=${btoa(JSON.stringify([{ source: 'sharedid.org', uids: [{ id: 'stale' }] }]))}`; mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 6ee858568..bc1f175b4 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -34,7 +34,7 @@ beforeAll(async () => { '--adapters', 'adf', '--user-id-modules', - 'sharedIdSystem', + 'sharedIdSystem,identityLinkIdSystem', '--out', outputDirectory, ]); @@ -132,7 +132,16 @@ describe('external bundle + served shim evaluated together', () => { // Mirror the server's head-injected state, which always precedes the // bundle script in document order. pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); - pageWindow.__tsjs_prebid = { clientSideBidders: [] }; + pageWindow.__tsjs_prebid = { + clientSideBidders: [], + liveRamp: { + placementId: '999', + notUse3P: false, + storageType: 'cookie', + expiresDays: 15, + refreshInSeconds: 1800, + }, + }; pageWindow.eval(bundleCode); @@ -144,7 +153,10 @@ describe('external bundle + served shim evaluated together', () => { 'adform', 'adformOpenRTB', ]); - expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); + expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual([ + 'sharedIdSystem', + 'identityLinkIdSystem', + ]); // Count trustedServer registrations across repeated shim evaluations. const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); @@ -154,6 +166,16 @@ describe('external bundle + served shim evaluated together', () => { pageWindow.eval(shimCode); const wrappedRequestBids = pageWindow.pbjs.requestBids; + expect(pageWindow.pbjs.getConfig('userSync.userIds')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + storage: expect.objectContaining({ name: 'idl_env' }), + }), + ]) + ); + // A second evaluation (double script inclusion, or a legacy bundle that // still carries a baked-in shim running after this one) must be a no-op. pageWindow.eval(shimCode); From aee43962766edc751e96bdb8f8046ded55a89fc0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:41:31 +0530 Subject: [PATCH 07/26] Cover LiveRamp Prebid bundle and EID behavior --- .../src/auction/endpoints.rs | 40 +++++++++++++++++++ crates/trusted-server-core/src/consent/mod.rs | 27 ++++++++++++- .../trusted-server-core/src/ec/prebid_eids.rs | 23 +++++++++++ .../lib/test/build-prebid-external.test.mjs | 27 +++++++++++++ .../prebid/user_id_modules.test.ts | 16 ++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c4af6fd3d..474c64dda 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1258,6 +1258,46 @@ mod tests { ); } + #[test] + fn merge_auction_eids_deduplicates_liveramp_and_prefers_resolved_metadata() { + let client_eids = Some(vec![Eid { + source: "liveramp.com".to_string(), + uids: vec![Uid { + id: "opaque-test-envelope".to_string(), + atype: None, + ext: Some(json!({ "origin": "client" })), + }], + }]); + let resolved_eids = Some(vec![Eid { + source: "liveramp.com".to_string(), + uids: vec![Uid { + id: "opaque-test-envelope".to_string(), + atype: Some(3), + ext: Some(json!({ "origin": "server" })), + }], + }]); + + let merged = + merge_auction_eids(client_eids, resolved_eids).expect("should merge LiveRamp EIDs"); + + assert_eq!(merged.len(), 1, "should retain one LiveRamp source"); + assert_eq!(merged[0].uids.len(), 1, "should deduplicate the envelope"); + assert_eq!( + merged[0].uids[0].id, "opaque-test-envelope", + "should preserve the opaque envelope" + ); + assert_eq!( + merged[0].uids[0].atype, + Some(3), + "should prefer resolved atype metadata" + ); + assert_eq!( + merged[0].uids[0].ext, + Some(json!({ "origin": "server" })), + "should prefer resolved extension metadata" + ); + } + #[test] fn auction_rejects_oversized_body() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f205a8363..49d41efad 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -703,7 +703,7 @@ mod tests { use super::{ ConsentPipelineInput, allows_ec_creation, apply_expiration_check, apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, - consent_allows_server_side_auction, has_explicit_ec_withdrawal, + consent_allows_server_side_auction, gate_eids_by_consent, has_explicit_ec_withdrawal, }; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ @@ -1080,6 +1080,31 @@ mod tests { TcfBuilder::new().with_storage(has_storage).build() } + #[test] + fn gate_eids_by_consent_removes_liveramp_when_personalization_is_denied() { + let context = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + gdpr_applies: true, + tcf: Some( + TcfBuilder::new() + .with_storage(true) + .with_personalized_ads(false) + .build(), + ), + ..ConsentContext::default() + }; + + let gated = gate_eids_by_consent( + Some(vec![("liveramp.com", "opaque-test-envelope")]), + Some(&context), + ); + + assert!( + gated.is_none(), + "should remove LiveRamp EIDs when personalization consent is denied" + ); + } + #[test] fn ec_allowed_gdpr_with_storage_consent() { let ctx = ConsentContext { diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 2f99c8182..fbaa007e8 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -682,6 +682,29 @@ mod tests { ); } + #[test] + fn ingest_liveramp_eid_cookie_preserves_the_opaque_envelope() { + let registry = make_registry(vec![("liveramp", "liveramp.com")]); + let cookie = encode_json(&json!([ + { + "source": "liveramp.com", + "uids": [{"id": "opaque-test-envelope", "atype": 3}] + } + ])); + let writer = RecordingWriter::default(); + + ingest_eid_cookies_with_writer(Some(&cookie), None, "ec-id", &writer, ®istry); + + let calls = writer.calls.borrow(); + assert_eq!(calls.len(), 1, "should perform one bulk writer call"); + assert_eq!(calls[0].len(), 1, "should write one LiveRamp partner ID"); + assert_eq!( + calls[0][0], + PartnerIdUpdate::new("liveramp.com", "opaque-test-envelope"), + "should preserve the opaque envelope without decoding it" + ); + } + #[test] fn ingest_eid_cookies_sharedid_cookie_overrides_prebid_sharedid_update() { let registry = make_registry(vec![("sharedid", "sharedid.org")]); diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index f22717f79..1997cac95 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -79,6 +79,33 @@ describe('build-prebid-external metadata', () => { } }, 120_000); + it('builds and stamps identityLinkIdSystem when explicitly selected', async () => { + const outputDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'trusted-server-liveramp-prebid-build-test-') + ); + + try { + await main([ + '--adapters', + 'rubicon', + '--user-id-modules', + 'identityLinkIdSystem', + '--out', + outputDirectory, + ]); + + const manifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ); + const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + + expect(manifest.userIdModules).toEqual(['identityLinkIdSystem']); + expect(bundle).toContain('identityLinkIdSystem'); + } finally { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } + }, 120_000); + it('resolves relative output paths against the current working directory', () => { const parsed = parseArgs(['--adapters', 'rubicon', '--out', 'dist/prebid']); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts index 2832a4082..1cd0a19b1 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts @@ -4,6 +4,7 @@ import { knownUserIdConfigNames, resolvePrebidUserIdModulesFromEids, } from '../../../src/integrations/prebid/user_id_modules'; +import registry from '../../../src/integrations/prebid/user_id_modules.json'; const sampleEids = [ { source: 'yahoo.com', uids: [{ id: 'connect-id', atype: 3 }] }, @@ -85,6 +86,21 @@ describe('prebid user ID module registry', () => { }); }); + it('maps LiveRamp EIDs to identityLinkIdSystem', () => { + expect( + resolvePrebidUserIdModulesFromEids([ + { source: 'liveramp.com', uids: [{ id: 'opaque-envelope', atype: 3 }] }, + ]) + ).toEqual({ + modules: ['userId', 'identityLinkIdSystem'], + missingSources: [], + }); + }); + + it('includes identityLinkIdSystem in the checked-in default preset', () => { + expect(registry.defaultPreset).toContain('identityLinkIdSystem'); + }); + it('maps unknown LiveIntent provider-backed sources to liveIntentIdSystem', () => { const result = resolvePrebidUserIdModulesFromEids([ { From 4724a6378ef29b05ed4ef755f837b5001212678f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:43:09 +0530 Subject: [PATCH 08/26] Document managed LiveRamp RampID setup --- docs/guide/configuration.md | 51 +++++++++----- docs/guide/integrations/prebid.md | 106 +++++++++++++++++++++++++++++- trusted-server.example.toml | 10 +++ 3 files changed, 150 insertions(+), 17 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ddb6544ce..aa3e9e683 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1049,22 +1049,27 @@ apply when the integration section exists in `trusted-server.toml`. **Section**: `[integrations.prebid]` -| Field | Type | Default | Description | -| -------------------------- | ------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Prebid integration | -| `server_url` | String | Required | Prebid Server endpoint URL | -| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | -| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | -| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | -| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | -| `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | -| `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | -| `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| Field | Type | Default | Description | +| ----------------------------- | ------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | Boolean | `true` | Enable Prebid integration | +| `server_url` | String | Required | Prebid Server endpoint URL | +| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | +| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | +| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | +| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | +| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | +| `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | +| `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | +| `debug_query_params` | String | `None` | Extra query params appended for debugging | +| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | +| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| `liveramp.placement_id` | String | Required when subsection exists | Numeric LiveRamp Placement ID for Prebid IdentityLink | +| `liveramp.not_use_3p` | Boolean | `false` | Disable cookie-recognized RampID envelopes when `true` | +| `liveramp.storage_type` | String | `cookie` | Browser storage used by IdentityLink: `cookie` or `html5` | +| `liveramp.expires_days` | Integer | `15` | Envelope storage lifetime in days; valid range is 1–30 | +| `liveramp.refresh_in_seconds` | Integer | `1800` | Positive interval before retrieving a potentially refreshed envelope | APS is configured exclusively under `[integrations.aps]`. `aps` entries in `bidders` or `client_side_bidders` are logged and removed case-insensitively so @@ -1089,6 +1094,13 @@ client_side_bidders = ["rubicon"] # Customize script interception (optional) script_patterns = ["/prebid.js", "/prebid.min.js"] +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 + [integrations.prebid.bid_param_overrides.criteo] networkId = 99999 pubid = "server-pub" @@ -1117,8 +1129,15 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG_QUERY_PARAMS=debug=1 TRUSTED_SERVER__INTEGRATIONS__PREBID__SCRIPT_PATTERNS='["/prebid.js","/prebid.min.js"]' +TRUSTED_SERVER__INTEGRATIONS__PREBID__LIVERAMP__PLACEMENT_ID=999 ``` +The LiveRamp storage name is fixed to `idl_env`. The Placement ID and an +approved publisher origin are operational prerequisites, and the external +Prebid bundle must include `identityLinkIdSystem`. See +[Managed LiveRamp RampID](/guide/integrations/prebid#managed-liveramp-rampid) +for consent, timing, privacy, degraded behavior, and live validation guidance. + **Script Pattern Matching**: The `script_patterns` configuration determines which Prebid scripts are intercepted and replaced with empty JavaScript responses. This prevents client-side Prebid.js from loading when using server-side bidding. diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 32f2827fb..355a17eba 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -39,6 +39,14 @@ excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] +# Optional operator-owned LiveRamp RampID configuration. +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 + # Required when external_bundle_url is configured. Include the bundle host and # any HTTPS redirect targets used by that host. [proxy] @@ -47,7 +55,7 @@ allowed_domains = ["assets.example"] # External bundle generation inputs used by `ts prebid bundle`. [integrations.prebid.bundle] adapters = ["rubicon"] -user_id_modules = ["sharedIdSystem"] +user_id_modules = ["sharedIdSystem", "identityLinkIdSystem"] # Optional static per-bidder param overrides (shallow merge) [integrations.prebid.bid_param_overrides.criteo] @@ -90,6 +98,11 @@ set = { placementId = "_s2sHeaderPlacement" } | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | | `bundle.adapters` | Array[String] | Required for `ts prebid bundle` | Prebid.js bidder adapter modules imported into the generated external browser bundle | | `bundle.user_id_modules` | Array[String] | Generator default preset when omitted | Prebid User ID modules imported into the generated external browser bundle | +| `liveramp.placement_id` | String | Required when subsection exists | Numeric LiveRamp Placement ID for the approved publisher origin | +| `liveramp.not_use_3p` | Boolean | `false` | Disable cookie-recognized RampID envelopes when `true` | +| `liveramp.storage_type` | String | `cookie` | Browser storage used by IdentityLink: `cookie` or `html5` | +| `liveramp.expires_days` | Integer | `15` | Envelope storage lifetime in days; valid range is 1–30 | +| `liveramp.refresh_in_seconds` | Integer | `1800` | Positive interval before retrieving a potentially refreshed envelope | ## External Bundle Generation @@ -458,6 +471,97 @@ Example EID source mapping: User ID module selection is separate from `--adapters`, which controls client-side bidder adapter modules. +## Managed LiveRamp RampID + +Trusted Server can configure Prebid's `identityLink` User ID submodule and +forward the resulting RampID identity envelope through the existing EID path. +This feature does not collect email addresses, hash identifiers, call a +server-to-server ATS API, or expose the encrypted envelope to application code. + +### Prerequisites + +Before enabling the subsection, obtain a test or production Placement ID from +LiveRamp, have the exact publisher origin approved by LiveRamp, and confirm the +publisher's CMP and LiveRamp contract permit the intended recognition mode. +The external Prebid bundle must include `identityLinkIdSystem`: + +```toml +[integrations.prebid.bundle] +adapters = ["rubicon"] +user_id_modules = ["identityLinkIdSystem"] + +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 +``` + +Run `ts prebid bundle`, upload the generated content-addressed bundle, copy its +hash metadata into `[integrations.prebid]`, and validate the configuration +before rollout. The storage name is fixed to `idl_env`; operators choose only +the storage type, expiry, and refresh interval. + +When enabled, Trusted Server owns one deterministic `identityLink` entry in +`userSync.userIds`. Other publisher-configured User ID entries are preserved, +but publisher attempts to add, remove, or replace `identityLink` are normalized +back to the operator-managed values. Including `identityLinkIdSystem` in a +bundle is inert until this configuration is enabled. + +### Resolution timing and data flow + +IdentityLink resolves asynchronously. A new browser's first auction can run +before RampID is available; later auctions can include it without blocking the +page or auction. When available, the opaque value follows the standard path: + +1. `pbjs.getUserIdsAsEids()` exposes an entry whose source is `liveramp.com`. +2. The current `/auction` request includes that entry. +3. Trusted Server merges and consent-gates it, then forwards it to Prebid + Server as `user.ext.eids`. +4. The browser persists the same opaque value in the bounded `ts-eids` cookie. +5. A later request can ingest it into an EC/KV partner configured with + `source_domain = "liveramp.com"`. + +Trusted Server treats the RampID envelope as an opaque string. Do not log, +decode, publish, or dimension metrics by the value. Source names, counts, +booleans, and status codes are sufficient for diagnostics. + +### Degraded behavior + +| Condition | Result | +| ------------------------------------------------- | ---------------------------------------------------------------------- | +| Consent is absent or the user opts out | No LiveRamp EID is forwarded; the auction continues | +| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | +| LiveRamp network resolution fails | The current auction continues without RampID | +| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | +| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | +| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | + +### Credential-based validation + +Live validation must run outside CI on a LiveRamp-approved non-production +origin. Never commit a live Placement ID or envelope. Record only the approved +domain, booleans, source names, counts, and status codes: + +1. Build a bundle containing `identityLinkIdSystem` and configure the test + Placement ID. +2. With positive consent, confirm `idl_env` is created or refreshed. +3. Confirm `pbjs.getUserIdsAsEids()` reports source `liveramp.com` without + recording its value. +4. Confirm a controlled Prebid Server request contains that source in + `user.ext.eids`. +5. Confirm a later request ingests the source into the configured + `liveramp.com` EC partner. +6. Repeat with opt-out or no consent and confirm no LiveRamp EID is forwarded. +7. Repeat on an unapproved origin and confirm identity resolution degrades + without blocking the auction. + +This integration forwards RampID identity envelopes through the Prebid auction +path. LiveRamp ATS Direct audience segments, including `_lr_atsDirect` storage +and GAM or Prebid segment activation, require a separate integration and are +not passed by this implementation. + ## Identity Forwarding Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions: diff --git a/trusted-server.example.toml b/trusted-server.example.toml index cb10906b0..02f8c869b 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -57,6 +57,16 @@ client_side_bidders = [] adapters = ["rubicon"] # user_id_modules = ["sharedIdSystem"] +# Optional managed LiveRamp RampID configuration. The external Prebid bundle +# must contain identityLinkIdSystem. Obtain the Placement ID and approve the +# publisher origin with LiveRamp before enabling. +# [integrations.prebid.liveramp] +# placement_id = "999" +# not_use_3p = false +# storage_type = "cookie" +# expires_days = 15 +# refresh_in_seconds = 1800 + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] From a3a1adf97048ed2a4cd63df8cabe8d1831b7dac6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 12:51:38 +0530 Subject: [PATCH 09/26] Format LiveRamp API documentation for clippy --- .../trusted-server-core/src/integrations/prebid.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 4c76981bd..2a7e195d4 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -220,28 +220,28 @@ fn validate_liveramp_placement_id(value: &str) -> Result<(), ValidationError> { Err(error) } -/// Browser storage mechanism used by Prebid's LiveRamp IdentityLink module. +/// Browser storage mechanism used by Prebid's `LiveRamp` `IdentityLink` module. #[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum PrebidLiveRampStorageType { - /// Store the opaque RampID envelope in a browser cookie. + /// Store the opaque `RampID` envelope in a browser cookie. #[default] Cookie, - /// Store the opaque RampID envelope in browser local storage. + /// Store the opaque `RampID` envelope in browser local storage. Html5, } -/// Operator-owned configuration for Prebid's LiveRamp IdentityLink module. +/// Operator-owned configuration for Prebid's `LiveRamp` `IdentityLink` module. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct PrebidLiveRampConfig { - /// Numeric Placement ID assigned by LiveRamp for the approved publisher origin. + /// Numeric Placement ID assigned by `LiveRamp` for the approved publisher origin. #[validate(custom(function = "validate_liveramp_placement_id"))] pub placement_id: String, /// Disable third-party-cookie recognition when `true`. #[serde(default)] pub not_use_3p: bool, - /// Browser storage mechanism for the opaque RampID envelope. + /// Browser storage mechanism for the opaque `RampID` envelope. #[serde(default)] pub storage_type: PrebidLiveRampStorageType, /// Number of days for which the browser stores the opaque envelope. @@ -265,7 +265,7 @@ pub struct PrebidIntegrationConfig { /// it in JavaScript. #[serde(default)] pub account_id: Option, - /// Optional managed LiveRamp RampID configuration for Prebid.js. + /// Optional managed `LiveRamp` `RampID` configuration for Prebid.js. #[serde(default)] #[validate(nested)] pub liveramp: Option, From 42c23bb5c735e133e36fcf1a3d1e83de7818e617 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 13:00:10 +0530 Subject: [PATCH 10/26] Guard managed LiveRamp merge configuration --- .../lib/src/integrations/prebid/index.ts | 17 +++++- .../test/integrations/prebid/index.test.ts | 61 ++++++++++++++++++- docs/guide/integrations/prebid.md | 18 +++--- 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 4678d35ad..db701aa54 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -1169,9 +1169,13 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const managedPbjs = pbjs as typeof pbjs & Record; if (injected?.liveRamp && managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { const originalSetConfig = pbjs.setConfig.bind(pbjs); + const prebidConfigApi = pbjs as typeof pbjs & { + mergeConfig?: typeof pbjs.setConfig; + }; + const originalMergeConfig = prebidConfigApi.mergeConfig?.bind(pbjs); const managedEntry = liveRampUserId(injected.liveRamp); - pbjs.setConfig = ((publisherConfig: PbjsConfig) => { + const normalizePublisherConfig = (publisherConfig: PbjsConfig): PbjsConfig => { let nextConfig = publisherConfig; try { if (hasUserIdsPath(publisherConfig)) { @@ -1180,8 +1184,17 @@ export function installPrebidNpm(config?: Partial): typeof pbjs } catch { log.error('[tsjs-prebid] LiveRamp configuration could not be normalized'); } - return originalSetConfig(nextConfig); + return nextConfig; + }; + + pbjs.setConfig = ((publisherConfig: PbjsConfig) => { + return originalSetConfig(normalizePublisherConfig(publisherConfig)); }) as typeof pbjs.setConfig; + if (originalMergeConfig) { + prebidConfigApi.mergeConfig = ((publisherConfig: PbjsConfig) => { + return originalMergeConfig(normalizePublisherConfig(publisherConfig)); + }) as typeof pbjs.setConfig; + } managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] = true; const getConfig = (pbjs as unknown as { getConfig?: (key?: string) => unknown }).getConfig; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index e33cf68ae..b11c82776 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -129,6 +129,7 @@ interface TestAdapterSpec { // of mocking module imports. const { mockSetConfig, + mockMergeConfig, mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, @@ -140,6 +141,9 @@ const { mockPbjs, } = vi.hoisted(() => { const mockSetConfig = vi.fn(); + // Prebid's public mergeConfig closes over its internal setConfig rather than + // calling pbjs.setConfig, so wrapping setConfig alone cannot intercept it. + const mockMergeConfig = vi.fn((config: unknown) => mockSetConfig(config)); const mockProcessQueue = vi.fn(); const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); @@ -160,6 +164,7 @@ const { }); const mockPbjs: { setConfig: typeof mockSetConfig; + mergeConfig: typeof mockMergeConfig; processQueue: typeof mockProcessQueue; requestBids: typeof mockRequestBids; registerBidAdapter: typeof mockRegisterBidAdapter; @@ -172,6 +177,7 @@ const { [key: string]: unknown; } = { setConfig: mockSetConfig, + mergeConfig: mockMergeConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, @@ -201,6 +207,7 @@ const { return { mockSetConfig, + mockMergeConfig, mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, @@ -231,6 +238,7 @@ import envelope from '../../fixtures/aps-renderer-v1.json'; beforeEach(() => { delete testWindow.__tsjsPrebidShimInstalled; mockPbjs.setConfig = mockSetConfig; + mockPbjs.mergeConfig = mockMergeConfig; mockPbjs.processQueue = mockProcessQueue; delete mockPbjs['__tsLiveRampSetConfigInstalled']; }); @@ -431,6 +439,7 @@ describe('prebid/installPrebidNpm', () => { // Reset requestBids to the mock so each test starts fresh mockPbjs.requestBids = mockRequestBids; mockPbjs.setConfig = mockSetConfig; + mockPbjs.mergeConfig = mockMergeConfig; mockPbjs.processQueue = mockProcessQueue; mockPbjs.adUnits = []; mockPbjs.que = []; @@ -814,12 +823,14 @@ describe('prebid/installPrebidNpm', () => { expect(mockProcessQueue).toHaveBeenCalledTimes(1); }); - it('leaves setConfig unchanged when LiveRamp is not configured', () => { + it('leaves the public config APIs unchanged when LiveRamp is not configured', () => { const originalSetConfig = mockPbjs.setConfig; + const originalMergeConfig = mockPbjs.mergeConfig; installPrebidNpm(); expect(mockPbjs.setConfig).toBe(originalSetConfig); + expect(mockPbjs.mergeConfig).toBe(originalMergeConfig); expect(mockSetConfig.mock.calls.some(([value]) => value?.userSync?.userIds)).toBe(false); }); @@ -926,6 +937,50 @@ describe('prebid/installPrebidNpm', () => { ]); }); + it('normalizes queued identityLink updates made through mergeConfig', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + mockPbjs.que = [ + () => + mockPbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }), + ]; + mockProcessQueue.mockImplementation(() => { + for (const callback of mockPbjs.que.splice(0)) callback(); + }); + + installPrebidNpm(); + + expect(mockMergeConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'sharedId' }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('normalizes late identityLink updates made through mergeConfig', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + + mockPbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'id5Id', params: { partner: 1 } }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }); + + expect(mockMergeConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'id5Id', params: { partner: 1 } }, + EXPECTED_IDENTITY_LINK, + ]); + }); + it('passes unrelated publisher configuration through by reference', () => { testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; installPrebidNpm(); @@ -936,15 +991,17 @@ describe('prebid/installPrebidNpm', () => { expect(mockSetConfig.mock.calls.at(-1)?.[0]).toBe(publisherConfig); }); - it('does not stack the LiveRamp setConfig wrapper across shim reinstallations', () => { + it('does not stack the LiveRamp config wrappers across shim reinstallations', () => { testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; installPrebidNpm(); const managedSetConfig = mockPbjs.setConfig; + const managedMergeConfig = mockPbjs.mergeConfig; delete testWindow.__tsjsPrebidShimInstalled; installPrebidNpm(); expect(mockPbjs.setConfig).toBe(managedSetConfig); + expect(mockPbjs.mergeConfig).toBe(managedMergeConfig); mockPbjs.setConfig({ userSync: { userIds: [] } }); expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([EXPECTED_IDENTITY_LINK]); }); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 355a17eba..c13949771 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -476,7 +476,7 @@ client-side bidder adapter modules. Trusted Server can configure Prebid's `identityLink` User ID submodule and forward the resulting RampID identity envelope through the existing EID path. This feature does not collect email addresses, hash identifiers, call a -server-to-server ATS API, or expose the encrypted envelope to application code. +server-to-server ATS API, or add a new application-facing envelope API. ### Prerequisites @@ -529,14 +529,14 @@ booleans, and status codes are sufficient for diagnostics. ### Degraded behavior -| Condition | Result | -| ------------------------------------------------- | ---------------------------------------------------------------------- | -| Consent is absent or the user opts out | No LiveRamp EID is forwarded; the auction continues | -| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | -| LiveRamp network resolution fails | The current auction continues without RampID | -| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | -| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | -| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | +| Condition | Result | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Required consent is absent in a regulated jurisdiction, or the user opts out | No LiveRamp EID is forwarded; the auction continues | +| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | +| LiveRamp network resolution fails | The current auction continues without RampID | +| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | +| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | +| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | ### Credential-based validation From 930a099aded4779383f0733a5c65d4a4ced44767 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 14:14:00 +0530 Subject: [PATCH 11/26] Clarify managed LiveRamp config APIs --- .../2026-08-21-liveramp-integration-design.md | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md index ee04b04e5..834706a98 100644 --- a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -306,11 +306,13 @@ Publisher commands may already be waiting in `window.pbjs.que`, including a Prebid processes existing commands in insertion order, so a publisher auction could run before the new entry. -When LiveRamp is configured, the shim instead installs a narrowly scoped, -idempotent wrapper around `pbjs.setConfig` before calling `pbjs.processQueue()`: +When LiveRamp is configured, the shim instead installs narrowly scoped, +idempotent wrappers around the public `pbjs.setConfig` and `pbjs.mergeConfig` +APIs before calling `pbjs.processQueue()`: -1. Capture and bind the real `pbjs.setConfig` implementation. -2. Replace `pbjs.setConfig` with a wrapper that only normalizes calls containing +1. Capture and bind the real `pbjs.setConfig` and, when present, + `pbjs.mergeConfig` implementations. +2. Replace both public APIs with wrappers that only normalize calls containing `userSync.userIds`. Other configuration calls pass through unchanged. 3. For a `userSync.userIds` call, preserve every non-`identityLink` entry, remove all publisher-supplied `identityLink` entries, and append exactly one @@ -322,11 +324,17 @@ idempotent wrapper around `pbjs.setConfig` before calling `pbjs.processQueue()`: after the external Prebid bundle loaded but before the deferred TSJS shim. An absent or malformed effective list degrades to an empty publisher list. Complete this step before processing any existing queue entries. -5. Call `pbjs.processQueue()`. Queued publisher `setConfig` calls flow through - the wrapper, so a later queued `requestBids` observes the managed entry. -6. Keep the wrapper installed after queue processing so later publisher - `setConfig` calls cannot silently replace or delete the operator-owned - LiveRamp policy. Repeated TSJS installation must not stack wrappers. +5. Call `pbjs.processQueue()`. Queued publisher `setConfig` and `mergeConfig` + calls flow through the wrappers, so a later queued `requestBids` observes + the managed entry. +6. Keep the wrappers installed after queue processing so later publisher calls + through either public configuration API cannot silently replace or delete + the operator-owned LiveRamp policy. Repeated TSJS installation must not + stack wrappers. + +This is configuration ownership for supported Prebid API usage, not a security +boundary against adversarial same-origin JavaScript that retained an earlier +function reference or mutates internal configuration objects directly. This policy gives the operator ownership of the single `identityLink` entry when `[integrations.prebid.liveramp]` exists. Publishers retain ownership of all @@ -351,7 +359,7 @@ sequenceDiagram O->>TS: Configure integrations.prebid.liveramp TS-->>B: Inject liveRamp config and managed Prebid bundle - B->>B: Install setConfig guard and synchronously merge identityLink + B->>B: Guard setConfig/mergeConfig and synchronously merge identityLink B->>LR: Prebid identityLink module resolves/refreshes envelope LR-->>B: Opaque RampID envelope B->>B: pbjs.getUserIdsAsEids() @@ -439,9 +447,11 @@ Add tests in while the managed `identityLink` entry is added; - malformed pre-install `userSync.userIds` state degrades to the managed entry without throwing; -- a publisher `identityLink` update after `processQueue()` is normalized back - to the operator-managed values; -- repeated installation does not stack `setConfig` wrappers; +- queued and late publisher `identityLink` updates through `mergeConfig` are + normalized back to the operator-managed values; +- a publisher `identityLink` update through `setConfig` after `processQueue()` + is normalized back to the operator-managed values; +- repeated installation does not stack either configuration wrapper; - configuration calls unrelated to `userSync.userIds` pass through unchanged; - missing `identityLinkIdSystem` appears in existing diagnostics; - `getUserIdsAsEids()` output for `liveramp.com` enters the current auction; From 83c3684979e1bacb7c32e2ceec8827b466479934 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 14:19:01 +0530 Subject: [PATCH 12/26] Align LiveRamp plan with public config guards --- .../plans/2026-08-21-liveramp-integration.md | 90 +++++++++++++------ 1 file changed, 65 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/plans/2026-08-21-liveramp-integration.md b/docs/superpowers/plans/2026-08-21-liveramp-integration.md index 0b85eb4cd..460386242 100644 --- a/docs/superpowers/plans/2026-08-21-liveramp-integration.md +++ b/docs/superpowers/plans/2026-08-21-liveramp-integration.md @@ -4,7 +4,7 @@ **Goal:** Make Trusted Server's existing Prebid RampID EID path first-class by adding validated operator configuration, deterministic `identityLink` setup, bundle diagnostics, tests, and documentation. -**Architecture:** Add an optional typed `liveramp` subsection to `PrebidIntegrationConfig` and serialize it into the existing `window.__tsjs_prebid` bootstrap. The TSJS Prebid shim installs an idempotent `pbjs.setConfig` normalizer before `processQueue()`, synchronously merges the operator-owned `identityLink` entry with effective publisher User ID entries, and preserves all unrelated configuration. Existing `/auction`, OpenRTB `user.ext.eids`, `ts-eids`, consent, and EC/KV paths remain unchanged. +**Architecture:** Add an optional typed `liveramp` subsection to `PrebidIntegrationConfig` and serialize it into the existing `window.__tsjs_prebid` bootstrap. The TSJS Prebid shim installs idempotent `pbjs.setConfig` and `pbjs.mergeConfig` normalizers before `processQueue()`, synchronously merges the operator-owned `identityLink` entry with effective publisher User ID entries, and preserves all unrelated configuration. Existing `/auction`, OpenRTB `user.ext.eids`, `ts-eids`, consent, and EC/KV paths remain unchanged. **Tech Stack:** Rust 2024, Serde, validator, TypeScript, Prebid.js 10, Vitest, JSDOM, Vite, VitePress/Markdown, Cargo workspace aliases. @@ -302,11 +302,12 @@ git commit -m "feat: configure LiveRamp for managed Prebid" - [ ] **Step 1: Reset mutable Prebid methods in the test harness** -The new feature intentionally wraps `pbjs.setConfig`. In each relevant +The new feature intentionally wraps `pbjs.setConfig` and `pbjs.mergeConfig`. In each relevant `beforeEach`, restore: ```typescript mockPbjs.setConfig = mockSetConfig +mockPbjs.mergeConfig = mockMergeConfig mockPbjs.processQueue = mockProcessQueue delete mockPbjs['__tsLiveRampSetConfigInstalled'] ``` @@ -341,8 +342,8 @@ const EXPECTED_IDENTITY_LINK = { Add tests proving: -1. Absent `liveRamp` config does not replace `mockPbjs.setConfig` and adds no - `identityLink` call. +1. Absent `liveRamp` config does not replace `mockPbjs.setConfig` or + `mockPbjs.mergeConfig` and adds no `identityLink` call. 2. Already-effective `getConfig('userSync.userIds')` entries are preserved and an existing `identityLink` is replaced exactly once. 3. An existing malformed effective value degrades to only the managed entry @@ -353,13 +354,15 @@ Add tests proving: entry. 6. A publisher `setConfig` after `processQueue()` cannot delete or replace the managed entry. -7. Calls unrelated to `userSync.userIds` retain their exact object shape. -8. A second installation does not stack wrappers. -9. The diagnostics report configured name `identityLink` and report it missing - when the external manifest omits `identityLinkIdSystem`. -10. Empty-string and structurally malformed LiveRamp EIDs are dropped before +7. Queued and late publisher `mergeConfig` calls cannot append, delete, or + replace the managed entry. +8. Calls unrelated to `userSync.userIds` retain their exact object shape. +9. A second installation does not stack either configuration wrapper. +10. The diagnostics report configured name `identityLink` and report it missing + when the external manifest omits `identityLinkIdSystem`. +11. Empty-string and structurally malformed LiveRamp EIDs are dropped before the auction request is serialized. -11. A valid opaque LiveRamp envelope survives the `ts-eids` cookie round trip +12. A valid opaque LiveRamp envelope survives the `ts-eids` cookie round trip byte-for-byte without being decoded. Use a local `mockProcessQueue` implementation that drains `mockPbjs.que` in @@ -424,6 +427,31 @@ expect(configuredUserIds).toEqual( Retain the current real `/auction` request assertion. Network remains stubbed; the test must not contact LiveRamp. +After the initial managed-entry assertion, call the real public merge API and +prove it cannot append a publisher-owned duplicate: + +```javascript +pageWindow.pbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, +}) + +const mergedUserIds = pageWindow.pbjs.getConfig('userSync.userIds') +expect(mergedUserIds.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), +]) +expect(mergedUserIds).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) +) +``` + - [ ] **Step 5: Run the focused unit and artifact suites and verify failures** Run: @@ -434,8 +462,8 @@ npx vitest run test/integrations/prebid/index.test.ts npx vitest run test/prebid-artifact-integration.test.mjs ``` -Expected: LiveRamp ownership tests fail because the injected shape and -`setConfig` normalizer do not exist, and the artifact test fails because no +Expected: LiveRamp ownership tests fail because the injected shape and public +configuration normalizers do not exist, and the artifact test fails because no managed entry is installed. The transport-only characterizations may already pass; retain them as evidence of the pre-existing path. @@ -540,20 +568,21 @@ fresh arrays. The spread operations preserve top-level properties and sibling `userSync` properties. Do not mutate publisher-owned arrays or objects in place. -- [ ] **Step 9: Install the idempotent `setConfig` guard before queue processing** +- [ ] **Step 9: Install idempotent public configuration guards before queue processing** In `installPrebidNpm`, after confirming the real Prebid API and before the existing base configuration and `processQueue()` call: 1. If injected `liveRamp` is absent, do nothing. -2. Capture and bind the current `pbjs.setConfig`. -3. Replace it with a wrapper that normalizes only calls containing - `userSync.userIds`; pass all other calls through unchanged. +2. Capture and bind the current `pbjs.setConfig` and optional + `pbjs.mergeConfig`. +3. Replace both public APIs with wrappers that share one normalizer for calls + containing `userSync.userIds`; pass all other calls through unchanged. 4. Mark the Prebid object with the sentinel so installation cannot stack. 5. Read effective User ID entries through `pbjs.getConfig`. 6. Call the wrapper synchronously with the effective list, producing one managed entry before any queued auction. -7. Leave the wrapper installed across `processQueue()` and later calls. +7. Leave both wrappers installed across `processQueue()` and later calls. Use logic equivalent to: @@ -561,9 +590,10 @@ Use logic equivalent to: const managedPbjs = pbjs as typeof pbjs & Record if (managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { const originalSetConfig = pbjs.setConfig.bind(pbjs) + const originalMergeConfig = pbjs.mergeConfig?.bind(pbjs) const managedEntry = liveRampUserId(config.liveRamp) - pbjs.setConfig = (publisherConfig) => { + const normalizePublisherConfig = (publisherConfig) => { let nextConfig = publisherConfig try { if (hasUserIdsPath(publisherConfig)) { @@ -572,7 +602,14 @@ if (managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { } catch { log.error('Prebid LiveRamp configuration could not be normalized') } - return originalSetConfig(nextConfig) + return nextConfig + } + + pbjs.setConfig = (publisherConfig) => + originalSetConfig(normalizePublisherConfig(publisherConfig)) + if (originalMergeConfig) { + pbjs.mergeConfig = (publisherConfig) => + originalMergeConfig(normalizePublisherConfig(publisherConfig)) } managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] = true @@ -582,10 +619,10 @@ if (managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { ``` Adapt the callback and return types to the repository's actual `pbjs` typing. -The original method is invoked exactly once, its return value is preserved, +Each original method is invoked exactly once, its return value is preserved, and normalization errors never log values. The sentinel lives on `pbjs`, not on the page-level shim state: a test must deliberately reset only -`__tsjsPrebidShimInstalled`, reinstall, and prove the wrapper reference is +`__tsjsPrebidShimInstalled`, reinstall, and prove both wrapper references are unchanged and publisher calls are normalized once. - [ ] **Step 10: Run focused unit and artifact tests and make them pass** @@ -599,8 +636,9 @@ npx vitest run test/prebid-artifact-integration.test.mjs ``` Expected: unit tests pass; the real bundle advertises both User ID modules, the -shim configures one `identityLink` entry, and the controlled auction still -reaches `/auction`. +shim configures one `identityLink` entry, a real `mergeConfig` call retains +exactly that managed entry, and the controlled auction still reaches +`/auction`. - [ ] **Step 11: Format, lint, and commit Task 2** @@ -765,8 +803,10 @@ In `docs/guide/integrations/prebid.md`, document: - prerequisites: Placement ID, approved origin, CMP/LiveRamp consent posture, and an external bundle containing `identityLinkIdSystem`; - the exact TOML example and `ts prebid bundle` selection; -- operator ownership of the single `identityLink` entry while preserving other - User ID modules; +- operator ownership of the single `identityLink` entry for calls through the + supported public `pbjs.setConfig` and `pbjs.mergeConfig` APIs while preserving + other User ID modules; explicitly state that this is not a security boundary + against retained pre-wrapper references or direct internal mutation; - asynchronous resolution: a new browser's first auction may have no RampID; - the existing flow through `getUserIdsAsEids()`, `/auction`, `user.ext.eids`, `ts-eids`, and EC/KV; From 76633571f565f3691f20b005e40b5a237e3348c2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 14:21:45 +0530 Subject: [PATCH 13/26] Cover LiveRamp merge behavior in real bundle --- .../test/prebid-artifact-integration.test.mjs | 23 +++++++++++++++++++ docs/guide/integrations/prebid.md | 12 ++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index bc1f175b4..e4c5e484b 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -176,6 +176,29 @@ describe('external bundle + served shim evaluated together', () => { ]) ); + // Exercise the real Prebid mergeConfig implementation. It closes over + // Prebid's internal setConfig, so the shim must guard mergeConfig itself + // to prevent a publisher-owned duplicate from bypassing the setConfig guard. + pageWindow.pbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }); + + const mergedUserIds = pageWindow.pbjs.getConfig('userSync.userIds'); + expect(mergedUserIds.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), + ]); + expect(mergedUserIds).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) + ); + // A second evaluation (double script inclusion, or a legacy bundle that // still carries a baked-in shim running after this one) must be a no-op. pageWindow.eval(shimCode); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index c13949771..92f15fe14 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -504,10 +504,14 @@ before rollout. The storage name is fixed to `idl_env`; operators choose only the storage type, expiry, and refresh interval. When enabled, Trusted Server owns one deterministic `identityLink` entry in -`userSync.userIds`. Other publisher-configured User ID entries are preserved, -but publisher attempts to add, remove, or replace `identityLink` are normalized -back to the operator-managed values. Including `identityLinkIdSystem` in a -bundle is inert until this configuration is enabled. +`userSync.userIds` for publisher configuration applied through the public +`pbjs.setConfig` and `pbjs.mergeConfig` APIs. Other publisher-configured User ID +entries are preserved, but calls through those APIs that add, remove, or replace +`identityLink` are normalized back to the operator-managed values. This is a +configuration-ownership convention, not a security boundary against same-origin +code that retained a pre-wrapper function reference or directly mutates Prebid's +internal configuration. Including `identityLinkIdSystem` in a bundle is inert +until this configuration is enabled. ### Resolution timing and data flow From 3e2c14a90cb02efe3a078c15f791e2214acfbc4f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 13:21:01 +0530 Subject: [PATCH 14/26] Enforce the TCF consent signal in the generated Prebid bundle The bundle imported consentManagementTcf, consentManagementGpp and consentManagementUsp, which only retrieve the consent signal. The activity controls that act on it live in Prebid's tcfControl module, which was not bundled, so a denied purpose changed nothing. Reproduced with the real bundle and shim in JSDOM against a CMP stub denying Purpose 1 and vendor 97: identityLink still issued a credentialed GET to LiveRamp's envelope endpoint, still wrote idl_env and its bookkeeping cookies, and the envelope still reached the ts-eids cookie. The run was identical to the consent-granted one. Server-side gating stops EID forwarding only, not the browser's collection and storage. Import tcfControl alongside the consent collectors and lock the behavior with a regression test that evaluates both production artifacts: denied purposes must produce no vendor call and no storage, granted purposes must still resolve. Note the scope: tcfControl enforces for every bidder and User ID module in the bundle, not only LiveRamp. Regenerating a bundle is a manual step, so this reaches a publisher only when `ts prebid bundle` is re-run. --- .../lib/build-prebid-external.mjs | 7 + .../test/prebid-consent-enforcement.test.mjs | 209 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index eb6e42826..24176fef8 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -241,6 +241,13 @@ function generateExternalEntry(entryFile, adapters, bidderCodes) { "import 'prebid.js/modules/consentManagementTcf.js';", "import 'prebid.js/modules/consentManagementGpp.js';", "import 'prebid.js/modules/consentManagementUsp.js';", + // consentManagement* only retrieves the consent signal. tcfControl is what + // registers the activity controls (accessDevice, syncUser, enrichEids, + // transmitEids, fetchBids) that act on it, so without it a TC string that + // denies a purpose changes nothing: User ID submodules still write storage + // and still call their vendor endpoints. Keep it bundled whenever + // consentManagementTcf is bundled. + "import 'prebid.js/modules/tcfControl.js';", "import 'prebid.js/modules/userId.js';", "import './_adapters.generated';", "import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';", diff --git a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs new file mode 100644 index 000000000..d62da8fc6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs @@ -0,0 +1,209 @@ +// @vitest-environment node + +// Proves the generated external Prebid bundle actually ENFORCES the TCF signal +// it collects. `consentManagementTcf` only retrieves the consent string; the +// activity controls that act on it live in `tcfControl`. Without that module a +// TC string denying Purpose 1 changes nothing: User ID submodules still write +// browser storage and still call their vendor endpoints. +// +// This matters most for the managed LiveRamp entry, which Trusted Server +// configures on the operator's behalf: the publisher never wrote the page code +// that turns it on, so the bundle is the only place enforcement can come from. +// +// Runs in the node environment (vite/esbuild cannot run under jsdom globals) +// and evaluates the artifacts in an explicit JSDOM window instead. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { JSDOM } from 'jsdom'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { main } from '../build-prebid-external.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const libDir = path.resolve(__dirname, '..'); + +const LIVE_RAMP_ENVELOPE_ENDPOINT = 'api.rlcdn.com'; +const LIVE_RAMP_STORAGE_NAME = 'idl_env'; +// LiveRamp's IAB Global Vendor List ID. +const LIVE_RAMP_GVL_VENDOR_ID = 97; + +let outputDirectory; +let bundleCode; +let shimCode; + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-consent-')); + + await main([ + '--adapters', + 'adf', + '--user-id-modules', + 'identityLinkIdSystem', + '--out', + outputDirectory, + ]); + const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); + bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + + const { build } = await import('vite'); + await build({ + configFile: false, + root: libDir, + build: { + emptyOutDir: false, + outDir: outputDirectory, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), + output: { + format: 'iife', + dir: outputDirectory, + entryFileNames: 'tsjs-prebid.js', + inlineDynamicImports: true, + extend: false, + name: 'tsjs_prebid', + }, + }, + }, + logLevel: 'warn', + }); + shimCode = fs.readFileSync(path.join(outputDirectory, 'tsjs-prebid.js'), 'utf8'); +}, 240_000); + +afterAll(() => { + fs.rmSync(outputDirectory, { recursive: true, force: true }); +}); + +// `tcfControl` reads the CMP's structured `vendorData`, not the encoded string, +// so the purpose and vendor grants below are what the rules actually evaluate. +// The string only has to be present and non-empty. +function tcData(granted) { + return { + gdprApplies: true, + tcString: 'CPexampleTCStringForTests', + eventStatus: 'tcloaded', + cmpStatus: 'loaded', + apiVersion: '2', + purpose: { + consents: { 1: granted, 3: granted, 4: granted }, + legitimateInterests: {}, + }, + vendor: { + consents: { [LIVE_RAMP_GVL_VENDOR_ID]: granted }, + legitimateInterests: {}, + }, + publisher: { restrictions: {} }, + specialFeatureOptins: {}, + }; +} + +/** + * Evaluates both artifacts on a GDPR page whose CMP grants or denies the + * purposes IdentityLink needs, then runs one auction. + * + * @returns the URLs the page requested and the cookies it managed to set. + */ +async function runGdprPage(granted) { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const pageWindow = dom.window; + + const requestedUrls = []; + pageWindow.fetch = vi.fn(async (resource) => { + requestedUrls.push(typeof resource === 'string' ? resource : resource?.url); + return new Response(JSON.stringify({ envelope: 'opaque-test-envelope' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + pageWindow.Request = class PageRequest extends Request { + constructor(resource, init) { + super( + typeof resource === 'string' ? new URL(resource, 'https://pub.example.com').href : resource, + init + ); + } + }; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) { + pageWindow.isSecureContext = true; + } + + const consentData = tcData(granted); + pageWindow.__tcfapi = (command, _version, callback) => { + if (command === 'addEventListener' || command === 'getTCData') { + callback(consentData, true); + } else if (command === 'removeEventListener') { + callback(true, true); + } + }; + + // Mirror the server's head-injected state, which always precedes the bundle + // script in document order. + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.__tsjs_prebid = { + clientSideBidders: [], + liveRamp: { + placementId: '999', + notUse3P: false, + storageType: 'cookie', + expiresDays: 15, + refreshInSeconds: 1800, + }, + }; + + pageWindow.eval(bundleCode); + pageWindow.pbjs.setConfig({ + consentManagement: { gdpr: { cmpApi: 'iab', timeout: 500, defaultGdprScope: true } }, + // Resolve User IDs before the auction so one auction is enough to observe + // whether IdentityLink ran. + userSync: { auctionDelay: 300, syncEnabled: false }, + }); + pageWindow.eval(shimCode); + + pageWindow.pbjs.requestBids({ adUnits: [], bidsBackHandler: () => {} }); + await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => pageWindow.setTimeout(resolve, 400)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + return { requestedUrls, cookies: pageWindow.document.cookie }; +} + +describe('external bundle TCF enforcement', () => { + it('bundles the activity-control module alongside the consent collectors', () => { + // A bundle that collects consent but cannot act on it is the failure mode + // this whole suite exists to prevent. + expect(bundleCode).toContain('consentManagementTcf'); + expect(bundleCode).toContain('tcfControl'); + }); + + it('blocks IdentityLink storage and vendor calls when Purpose 1 is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage(false); + + expect( + requestedUrls.filter((url) => String(url).includes(LIVE_RAMP_ENVELOPE_ENDPOINT)) + ).toEqual([]); + expect(cookies).not.toContain(LIVE_RAMP_STORAGE_NAME); + expect(cookies).not.toContain('_lr_retry_request'); + }); + + it('still resolves IdentityLink when the required purposes are granted', async () => { + const { requestedUrls, cookies } = await runGdprPage(true); + + expect( + requestedUrls.filter((url) => String(url).includes(LIVE_RAMP_ENVELOPE_ENDPOINT)) + ).toHaveLength(1); + expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); + }); +}); From d38c602f257b9d9982eda2c90c160f7c4e928cf5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 13:21:30 +0530 Subject: [PATCH 15/26] Reject LiveRamp configuration that omits its Prebid User ID module `[integrations.prebid.liveramp]` combined with a pinned `bundle.user_id_modules` list that lacks identityLinkIdSystem is valid config that builds a bundle unable to serve it. The managed identityLink entry the server injects has no submodule to drive, and the only signal is a browser-side diagnostic. Fail in `ts prebid bundle`, which already reads both keys from the same file. An absent list still takes the generator's default preset, which includes the module. --- .../trusted-server-cli/src/prebid_bundle.rs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 802d854ce..1b2abd768 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -11,6 +11,12 @@ pub(crate) type CliResult = Result; const NODE_MODULES_MISSING_HELP: &str = "Prebid bundling dependencies are missing. Run `cd crates/trusted-server-js/lib && npm ci`, then retry `ts prebid bundle`."; +/// Prebid User ID module that backs `[integrations.prebid.liveramp]`. +/// +/// Without it in the bundle the managed `identityLink` entry the server injects +/// has no submodule to drive, so `LiveRamp` silently resolves nothing. +const IDENTITY_LINK_USER_ID_MODULE: &str = "identityLinkIdSystem"; + #[derive(Debug, clap::Args)] pub(crate) struct PrebidBundleArgs { /// Trusted Server config path. @@ -235,6 +241,22 @@ pub(crate) fn load_bundle_config(config_path: &Path) -> CliResult Date: Mon, 24 Aug 2026 13:21:43 +0530 Subject: [PATCH 16/26] Harden the managed LiveRamp Prebid configuration wrapper Three fixes to the setConfig and mergeConfig guards: Skip seeding when getConfig is missing. `hasPrebidJsApi` only requires registerBidAdapter, so getConfig can be absent. The previous fallback read an empty entry list and then seeded the managed entry alone, silently dropping every publisher module already configured. Log and leave the wrappers installed instead, so a later publisher userIds call still gets the managed entry. Build the managed entry per call. Prebid retains what it receives as `submodule.config` for the life of the page, so one shared instance let any mutation there leak into every later normalization. Keep the normalization guard but report the error. Publisher configuration is arbitrary page data and a throwing accessor must not break the publisher's own setConfig call, so the guard stays; it now logs what failed instead of discarding it. The redundant hasUserIdsPath check is dropped since withManagedLiveRampUserId already performs it. --- .../lib/src/integrations/prebid/index.ts | 44 ++++++++------ .../test/integrations/prebid/index.test.ts | 60 +++++++++++++++++++ 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index db701aa54..96e95475a 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -255,9 +255,9 @@ function liveRampUserId(config: InjectedLiveRampConfig): PrebidUserIdConfigEntry } function withManagedLiveRampUserId( - config: Record, + config: PbjsConfig, managedEntry: PrebidUserIdConfigEntry -): Record { +): PbjsConfig { if (!hasUserIdsPath(config)) return config; const retained = configuredUserIdEntries(config.userSync.userIds).filter( @@ -269,7 +269,7 @@ function withManagedLiveRampUserId( ...config.userSync, userIds: [...retained, managedEntry], }, - }; + } as PbjsConfig; } function readConfiguredUserIdNames(): string[] { @@ -1167,24 +1167,27 @@ export function installPrebidNpm(config?: Partial): typeof pbjs }; const managedPbjs = pbjs as typeof pbjs & Record; - if (injected?.liveRamp && managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { + const liveRampConfig = injected?.liveRamp; + if (liveRampConfig && managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { const originalSetConfig = pbjs.setConfig.bind(pbjs); const prebidConfigApi = pbjs as typeof pbjs & { mergeConfig?: typeof pbjs.setConfig; }; const originalMergeConfig = prebidConfigApi.mergeConfig?.bind(pbjs); - const managedEntry = liveRampUserId(injected.liveRamp); const normalizePublisherConfig = (publisherConfig: PbjsConfig): PbjsConfig => { - let nextConfig = publisherConfig; try { - if (hasUserIdsPath(publisherConfig)) { - nextConfig = withManagedLiveRampUserId(publisherConfig, managedEntry) as PbjsConfig; - } - } catch { - log.error('[tsjs-prebid] LiveRamp configuration could not be normalized'); + // Build the managed entry per call rather than sharing one object. + // Prebid retains whatever it receives as `submodule.config` for the + // life of the page, so a shared instance would let any mutation there + // leak into every later normalization. + return withManagedLiveRampUserId(publisherConfig, liveRampUserId(liveRampConfig)); + } catch (error) { + // Publisher configuration is arbitrary page data: a throwing accessor + // must not break the publisher's own setConfig call. + log.error('[tsjs-prebid] LiveRamp configuration could not be normalized', error); + return publisherConfig; } - return nextConfig; }; pbjs.setConfig = ((publisherConfig: PbjsConfig) => { @@ -1198,11 +1201,18 @@ export function installPrebidNpm(config?: Partial): typeof pbjs managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] = true; const getConfig = (pbjs as unknown as { getConfig?: (key?: string) => unknown }).getConfig; - const effectiveUserIds = - typeof getConfig === 'function' - ? configuredUserIdEntries(getConfig.call(pbjs, 'userSync.userIds')) - : []; - pbjs.setConfig({ userSync: { userIds: effectiveUserIds } } as PbjsConfig); + if (typeof getConfig === 'function') { + const effectiveUserIds = configuredUserIdEntries(getConfig.call(pbjs, 'userSync.userIds')); + pbjs.setConfig({ userSync: { userIds: effectiveUserIds } } as PbjsConfig); + } else { + // Without getConfig the effective User ID entries cannot be read, and + // seeding the managed entry alone would silently drop every publisher + // module already configured. Leave the wrappers installed so the next + // publisher userIds call still gets the managed entry. + log.error( + '[tsjs-prebid] window.pbjs.getConfig is unavailable; managed LiveRamp entry not seeded' + ); + } } auctionEndpoint = merged.endpoint ?? '/auction'; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index b11c82776..600ab9eae 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -443,6 +443,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.processQueue = mockProcessQueue; mockPbjs.adUnits = []; mockPbjs.que = []; + mockPbjs.getConfig = mockGetConfig; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); mockGetConfig.mockReset(); @@ -1006,6 +1007,65 @@ describe('prebid/installPrebidNpm', () => { expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([EXPECTED_IDENTITY_LINK]); }); + it('skips seeding the managed entry when getConfig is unavailable', () => { + // Seeding needs the effective User ID entries. Without getConfig they + // cannot be read, and installing the managed entry alone would silently + // drop every publisher-configured module. + const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + delete (mockPbjs as { getConfig?: unknown }).getConfig; + + expect(() => installPrebidNpm()).not.toThrow(); + + expect(mockSetConfig.mock.calls.some(([value]) => value?.userSync?.userIds)).toBe(false); + expect(errorSpy).toHaveBeenCalledWith( + '[tsjs-prebid] window.pbjs.getConfig is unavailable; managed LiveRamp entry not seeded' + ); + + // The wrappers are still installed, so the next publisher userIds call + // still gets the managed entry. + mockPbjs.setConfig({ userSync: { userIds: [{ name: 'sharedId' }] } }); + expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'sharedId' }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('passes the publisher config through when normalization throws', () => { + const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + const hostileConfig = { + userSync: { + get userIds(): never { + throw new Error('example accessor failure'); + }, + }, + }; + + mockPbjs.setConfig(hostileConfig as unknown as Parameters[0]); + + expect(mockSetConfig.mock.calls.at(-1)?.[0]).toBe(hostileConfig); + expect(errorSpy).toHaveBeenCalledWith( + '[tsjs-prebid] LiveRamp configuration could not be normalized', + expect.any(Error) + ); + }); + + it('hands Prebid a distinct managed entry object per normalization', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + + mockPbjs.setConfig({ userSync: { userIds: [] } }); + const first = mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds[0]; + mockPbjs.setConfig({ userSync: { userIds: [] } }); + const second = mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds[0]; + + expect(first).toEqual(EXPECTED_IDENTITY_LINK); + expect(second).toEqual(EXPECTED_IDENTITY_LINK); + expect(second).not.toBe(first); + }); + it('reports identityLink missing when its bundle module is absent', () => { testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; testWindow.__tsjs_prebid_bundle = { userIdModules: [] }; From f5c68a6aed4009626d410acf82c6575e3ed5c239 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 13:22:10 +0530 Subject: [PATCH 17/26] Drop a duplicated auction EID test and widen the consent gating test merge_auction_eids_deduplicates_liveramp_and_prefers_resolved_metadata was merge_auction_eids_prefers_server_resolved_metadata_on_conflict with the source string changed. merge_auction_eids is source-agnostic, so it added no coverage. gate_eids_by_consent is all-or-nothing across sources. Rename its test and give it a second source so the name no longer implies per-source gating that does not exist. --- .../src/auction/endpoints.rs | 40 ------------------- crates/trusted-server-core/src/consent/mod.rs | 11 +++-- 2 files changed, 8 insertions(+), 43 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 8d7175e16..326453157 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1243,46 +1243,6 @@ mod tests { ); } - #[test] - fn merge_auction_eids_deduplicates_liveramp_and_prefers_resolved_metadata() { - let client_eids = Some(vec![Eid { - source: "liveramp.com".to_string(), - uids: vec![Uid { - id: "opaque-test-envelope".to_string(), - atype: None, - ext: Some(json!({ "origin": "client" })), - }], - }]); - let resolved_eids = Some(vec![Eid { - source: "liveramp.com".to_string(), - uids: vec![Uid { - id: "opaque-test-envelope".to_string(), - atype: Some(3), - ext: Some(json!({ "origin": "server" })), - }], - }]); - - let merged = - merge_auction_eids(client_eids, resolved_eids).expect("should merge LiveRamp EIDs"); - - assert_eq!(merged.len(), 1, "should retain one LiveRamp source"); - assert_eq!(merged[0].uids.len(), 1, "should deduplicate the envelope"); - assert_eq!( - merged[0].uids[0].id, "opaque-test-envelope", - "should preserve the opaque envelope" - ); - assert_eq!( - merged[0].uids[0].atype, - Some(3), - "should prefer resolved atype metadata" - ); - assert_eq!( - merged[0].uids[0].ext, - Some(json!({ "origin": "server" })), - "should prefer resolved extension metadata" - ); - } - #[test] fn auction_rejects_oversized_body() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 49d41efad..e149c76ee 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -1081,7 +1081,7 @@ mod tests { } #[test] - fn gate_eids_by_consent_removes_liveramp_when_personalization_is_denied() { + fn gate_eids_by_consent_strips_every_eid_when_personalization_is_denied() { let context = ConsentContext { jurisdiction: Jurisdiction::Gdpr, gdpr_applies: true, @@ -1094,14 +1094,19 @@ mod tests { ..ConsentContext::default() }; + // Gating is all-or-nothing across sources; LiveRamp is included here as + // the case that motivated this coverage, not as a special case. let gated = gate_eids_by_consent( - Some(vec![("liveramp.com", "opaque-test-envelope")]), + Some(vec![ + ("liveramp.com", "opaque-test-envelope"), + ("sharedid.org", "shared-test-id"), + ]), Some(&context), ); assert!( gated.is_none(), - "should remove LiveRamp EIDs when personalization consent is denied" + "should remove every EID when personalization consent is denied" ); } From 47e774dbb9a160000a9b45f7d87b069cdbd8c669 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 13:22:10 +0530 Subject: [PATCH 18/26] Build the injected LiveRamp payload through a From implementation The inline closure that mapped PrebidLiveRampConfig into its camel-cased injection struct pushed rustfmt into indenting the whole payload literal an extra level. A From implementation keeps the mapping next to the struct it builds and restores the surrounding formatting. --- .../src/integrations/prebid.rs | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index dc0fe8af6..186bb9127 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1149,6 +1149,18 @@ impl IntegrationHeadInjector for PrebidIntegration { refresh_in_seconds: u32, } + impl<'a> From<&'a PrebidLiveRampConfig> for InjectedPrebidLiveRampConfig<'a> { + fn from(config: &'a PrebidLiveRampConfig) -> Self { + Self { + placement_id: &config.placement_id, + not_use_3p: config.not_use_3p, + storage_type: config.storage_type, + expires_days: config.expires_days, + refresh_in_seconds: config.refresh_in_seconds, + } + } + } + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct InjectedPrebidClientConfig<'a> { @@ -1164,24 +1176,19 @@ impl IntegrationHeadInjector for PrebidIntegration { excluded_gam_ad_unit_path_suffixes: &'a [String], } - let payload = - InjectedPrebidClientConfig { - account_id: self.config.account_id.as_deref().unwrap_or_default(), - live_ramp: self.config.liveramp.as_ref().map(|config| { - InjectedPrebidLiveRampConfig { - placement_id: &config.placement_id, - not_use_3p: config.not_use_3p, - storage_type: config.storage_type, - expires_days: config.expires_days, - refresh_in_seconds: config.refresh_in_seconds, - } - }), - timeout: self.config.timeout_ms, - debug: self.config.debug, - bidders: &self.config.bidders, - client_side_bidders: &self.config.client_side_bidders, - excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes, - }; + let payload = InjectedPrebidClientConfig { + account_id: self.config.account_id.as_deref().unwrap_or_default(), + live_ramp: self + .config + .liveramp + .as_ref() + .map(InjectedPrebidLiveRampConfig::from), + timeout: self.config.timeout_ms, + debug: self.config.debug, + bidders: &self.config.bidders, + client_side_bidders: &self.config.client_side_bidders, + excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes, + }; // Escape ` Date: Mon, 24 Aug 2026 13:22:28 +0530 Subject: [PATCH 19/26] Document LiveRamp consent enforcement, network use, and storage The degraded-behavior table said only that no EID is forwarded when consent is absent, which read as though nothing happened. Separate the TCF case, where tcfControl now blocks resolution outright, from the US-state case, where the server's forwarding gate is what applies. Add the browser-side footprint the previous text omitted: the credentialed request the page makes to LiveRamp's envelope endpoint, that Trusted Server does not proxy it, the connect-src allowance a strict CSP needs, and every storage entry IdentityLink writes. Note that the submodule prefers an ATS library already on the page, and that this is therefore not a first-party replacement for LiveRamp recognition. Also record that the environment override cannot introduce the subsection, that `ts prebid bundle` rejects a module list without identityLinkIdSystem, that regenerating a bundle now enforces TCF for every bidder and User ID module, and that KV persistence needs a matching [[ec.partners]] entry. Correct the design document's consent section, which assumed the consent-management modules were themselves the enforcement point, and flag the remaining GPP gap. --- docs/guide/configuration.md | 5 +- docs/guide/integrations/prebid.md | 66 ++++++++++++++++--- .../2026-08-21-liveramp-integration-design.md | 10 +++ trusted-server.example.toml | 5 +- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bad78ec0b..0b70b886f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1271,7 +1271,10 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__LIVERAMP__PLACEMENT_ID=999 The LiveRamp storage name is fixed to `idl_env`. The Placement ID and an approved publisher origin are operational prerequisites, and the external -Prebid bundle must include `identityLinkIdSystem`. See +Prebid bundle must include `identityLinkIdSystem`. The environment override +above only replaces a `placement_id` that the published TOML already declares — +it cannot introduce the subsection, so LiveRamp cannot be enabled from the +environment alone. See [Managed LiveRamp RampID](/guide/integrations/prebid#managed-liveramp-rampid) for consent, timing, privacy, degraded behavior, and live validation guidance. diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 92f15fe14..54ae1f6e0 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -131,6 +131,15 @@ regenerating and re-uploading the bundle (and pushing the updated rollout. The shim refuses to install twice on one page via the `window.__tsjsPrebidShimInstalled` sentinel. +The consent modules include Prebid's `tcfControl`, so a regenerated bundle +**enforces** the TCF signal it collects rather than only reporting it. Under +GDPR, activities whose purposes the CMP denies — device access, user sync, EID +enrichment and transmission, bid requests — are blocked client-side for every +bidder and User ID module, not only for LiveRamp. Publishers whose CMP does not +actually grant the purposes their current setup relies on will see EU activity +drop once they regenerate, so validate a regenerated bundle against a live CMP +before rolling it out broadly. + ## Debug Mode When `debug = true`, the Prebid integration enables additional diagnostics on both the outgoing OpenRTB request and the incoming response. @@ -501,7 +510,18 @@ refresh_in_seconds = 1800 Run `ts prebid bundle`, upload the generated content-addressed bundle, copy its hash metadata into `[integrations.prebid]`, and validate the configuration before rollout. The storage name is fixed to `idl_env`; operators choose only -the storage type, expiry, and refresh interval. +the storage type, expiry, and refresh interval. `ts prebid bundle` rejects a +config that sets `[integrations.prebid.liveramp]` while pinning +`bundle.user_id_modules` to a list without `identityLinkIdSystem`; omit the +list to take the generator's default preset, which includes it. + +The generated bundle carries Prebid's `tcfControl` module alongside the +`consentManagement*` modules. That pairing is what makes the TCF signal +enforceable: `consentManagement*` only retrieves the consent string, while +`tcfControl` registers the activity controls (device access, user sync, EID +enrichment and transmission) that act on it. A bundle built without it collects +consent and then ignores it — every User ID submodule would still write browser +storage and still call its vendor endpoint on a denied purpose. When enabled, Trusted Server owns one deterministic `identityLink` entry in `userSync.userIds` for publisher configuration applied through the public @@ -531,16 +551,41 @@ Trusted Server treats the RampID envelope as an opaque string. Do not log, decode, publish, or dimension metrics by the value. Source names, counts, booleans, and status codes are sufficient for diagnostics. +### Browser network and storage footprint + +With the default `not_use_3p = false`, the IdentityLink submodule performs +third-party recognition from the browser. Operators should plan for this before +enabling the subsection: + +| Effect | Detail | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Outbound request | A credentialed `GET` from the page to LiveRamp's envelope endpoint (`api.rlcdn.com`). Trusted Server does not proxy it. | +| Content Security Policy | Publishers running a strict CSP must allow that host in `connect-src`, or recognition fails silently. | +| Browser storage | `idl_env` plus IdentityLink's bookkeeping entries (`idl_env_cst`, `idl_env_last`, `_lr_retry_request`, `_lr_env_src_ats`). | +| Recognition opt-out | `not_use_3p = true` suppresses the third-party request. RampID then resolves only where an authenticated envelope is already available on the page. | + +Because the request leaves the browser directly rather than through the edge, +this integration is not a first-party replacement for LiveRamp recognition; it +configures Prebid's client-side submodule on the operator's behalf. Server-side +resolution is tracked separately (see the design document's out-of-scope +section). + +If the publisher's page already loads LiveRamp's ATS library, the submodule +prefers `window.ats.retrieveEnvelope` over the third-party endpoint. That is +the submodule's own behavior — Trusted Server neither loads ATS nor calls a +server-to-server ATS API. + ### Degraded behavior -| Condition | Result | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Required consent is absent in a regulated jurisdiction, or the user opts out | No LiveRamp EID is forwarded; the auction continues | -| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | -| LiveRamp network resolution fails | The current auction continues without RampID | -| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | -| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | -| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | +| Condition | Result | +| ------------------------------------------------- | ---------------------------------------------------------------------- | +| Required TCF purposes are denied | `tcfControl` blocks resolution: no vendor call, no `idl_env`, no EID | +| The user opts out under a US state signal | No LiveRamp EID is forwarded; the auction continues | +| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | +| LiveRamp network resolution fails | The current auction continues without RampID | +| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | +| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | +| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | ### Credential-based validation @@ -557,7 +602,8 @@ domain, booleans, source names, counts, and status codes: `user.ext.eids`. 5. Confirm a later request ingests the source into the configured `liveramp.com` EC partner. -6. Repeat with opt-out or no consent and confirm no LiveRamp EID is forwarded. +6. Repeat with denied consent and confirm the envelope endpoint is not called, + `idl_env` is not written, and no LiveRamp EID is forwarded. 7. Repeat on an unapproved origin and confirm identity resolution degrades without blocking the auction. diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md index 834706a98..d87b027d6 100644 --- a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -383,6 +383,16 @@ ingestion provide reuse on later requests. - Prebid's User ID and consent-management modules remain responsible for deciding whether the browser may call LiveRamp. LiveRamp must be configured correctly in the publisher's CMP/GVL posture. +- Correction applied during implementation: `consentManagementTcf` only + _retrieves_ the TC string. Enforcement lives in Prebid's `tcfControl` + activity-control module, which the generated external bundle did not carry. + Without it a denied Purpose 1 still permitted the vendor call and the + `idl_env` write; only EID _forwarding_ was gated, server-side. The bundle now + imports `tcfControl`, covered by + `crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs`. + Equivalent GPP/US-state activity controls (`gppControl_usnat`, + `gppControl_usstates`) remain unbundled; US opt-outs are still enforced only + at the server's forwarding gate. - LiveRamp envelope values are opaque identifiers. They must never appear in logs, public diagnostics, error bodies, or telemetry dimensions. - The implementation does not collect plaintext or hashed email and does not diff --git a/trusted-server.example.toml b/trusted-server.example.toml index a887a6fad..660d7e9df 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -59,7 +59,10 @@ adapters = ["rubicon"] # Optional managed LiveRamp RampID configuration. The external Prebid bundle # must contain identityLinkIdSystem. Obtain the Placement ID and approve the -# publisher origin with LiveRamp before enabling. +# publisher origin with LiveRamp before enabling. Persisting the resulting +# envelope into the EC identity graph additionally needs a matching partner +# under [[ec.partners]] with source_domain = "liveramp.com"; without one the +# envelope still reaches the auction but is never written to KV. # [integrations.prebid.liveramp] # placement_id = "999" # not_use_3p = false From 8f1810215e69f81e86911afd4fa8fdb806b0f016 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 14:05:34 +0530 Subject: [PATCH 20/26] Add host-target clippy aliases for the CLI and OpenRTB codegen CI lints trusted-server-cli and trusted-server-openrtb-codegen explicitly because no adapter-scoped alias covers them, but both invocations pin x86_64-unknown-linux-gnu. There was no command a developer could run locally to reproduce them, and CLAUDE.md's CI gate list named only the six adapter aliases, so running the documented gates locally passed while CI failed on a clippy::doc_markdown error in the CLI. Add clippy-cli and clippy-codegen. Both omit --target so they build for the host, and record them in the gate list. --- .cargo/config.toml | 7 +++++++ CLAUDE.md | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0..f8fcfadaf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -64,6 +64,13 @@ run_cli_macos = "run --package trusted-server-cli --target aarch64-apple-darwin test_cli_linux = "test --package trusted-server-cli --target x86_64-unknown-linux-gnu" test_cli_macos = "test --package trusted-server-cli --target aarch64-apple-darwin" +# --- Host-target lint gates that no adapter alias covers --- +# CI lints these two crates explicitly (see .github/workflows/format.yml), but +# pins the Linux triple, so there was no command a developer could run locally +# to reproduce them. These omit --target and therefore build for the host. +clippy-cli = "clippy -p trusted-server-cli --all-targets --all-features -- -D warnings" +clippy-codegen = "clippy -p trusted-server-openrtb-codegen --all-targets -- -D warnings" + # When a wasm binary IS built, run it under Viceroy. [target.'cfg(all(target_arch = "wasm32"))'] runner = "viceroy run -C ../../fastly.toml -- " diff --git a/CLAUDE.md b/CLAUDE.md index 546a3bf52..0447da1cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,12 @@ cargo clippy-cloudflare-wasm cargo clippy-spin-native cargo clippy-spin-wasm +# The CLI and the OpenRTB codegen crate are host-target members that no adapter +# alias covers. CI lints both with the Linux triple pinned; these aliases omit +# `--target` so they reproduce it on any host. +cargo clippy-cli +cargo clippy-codegen + # Check compilation (per-target aliases — bare `cargo check` fails at the workspace root) cargo check-fastly && cargo check-axum && cargo check-cloudflare @@ -336,7 +342,7 @@ IntegrationRegistration::builder(ID) Every PR must pass: 1. `cargo fmt --all -- --check` -2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm` +2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm && cargo clippy-cli && cargo clippy-codegen` 3. `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin` 4. `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity` 5. JS build and test (`cd crates/trusted-server-js/lib && npx vitest run`) From fb4b9db03fcab7798c048d496e6558b388c7fdad Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 15:22:00 +0530 Subject: [PATCH 21/26] Match the LiveRamp envelope host exactly in the consent enforcement test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged js/incomplete-url-substring-sanitization on both assertions. These are not sanitizers — the URLs come from the test's own fetch spy — so the alert is not a vulnerability, but the substring test is still too loose for an assertion: it would also match an unrelated host carrying api.rlcdn.com in its name or query string, letting the granted-consent case count the wrong request. Compare the parsed hostname instead. Verified the test is still non-vacuous: dropping the tcfControl import fails two of its three cases. --- .../test/prebid-consent-enforcement.test.mjs | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs index d62da8fc6..a5a78be12 100644 --- a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs @@ -26,11 +26,31 @@ import { main } from '../build-prebid-external.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const libDir = path.resolve(__dirname, '..'); -const LIVE_RAMP_ENVELOPE_ENDPOINT = 'api.rlcdn.com'; +const LIVE_RAMP_ENVELOPE_HOST = 'api.rlcdn.com'; const LIVE_RAMP_STORAGE_NAME = 'idl_env'; // LiveRamp's IAB Global Vendor List ID. const LIVE_RAMP_GVL_VENDOR_ID = 97; +/** + * Requests the page made to LiveRamp's envelope endpoint. + * + * Matches the parsed hostname rather than a substring: `includes()` would also + * match an unrelated host that merely carries this one in its name or query + * string, which could let the granted-consent assertion count the wrong + * request. + * + * @returns the matching URLs + */ +function envelopeRequests(urls) { + return urls.filter((url) => { + try { + return new URL(String(url), 'https://pub.example.com').hostname === LIVE_RAMP_ENVELOPE_HOST; + } catch { + return false; + } + }); +} + let outputDirectory; let bundleCode; let shimCode; @@ -191,9 +211,7 @@ describe('external bundle TCF enforcement', () => { it('blocks IdentityLink storage and vendor calls when Purpose 1 is denied', async () => { const { requestedUrls, cookies } = await runGdprPage(false); - expect( - requestedUrls.filter((url) => String(url).includes(LIVE_RAMP_ENVELOPE_ENDPOINT)) - ).toEqual([]); + expect(envelopeRequests(requestedUrls)).toEqual([]); expect(cookies).not.toContain(LIVE_RAMP_STORAGE_NAME); expect(cookies).not.toContain('_lr_retry_request'); }); @@ -201,9 +219,7 @@ describe('external bundle TCF enforcement', () => { it('still resolves IdentityLink when the required purposes are granted', async () => { const { requestedUrls, cookies } = await runGdprPage(true); - expect( - requestedUrls.filter((url) => String(url).includes(LIVE_RAMP_ENVELOPE_ENDPOINT)) - ).toHaveLength(1); + expect(envelopeRequests(requestedUrls)).toHaveLength(1); expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); }); }); From 6f38ba3b26436d53f9ff9f477831fd0e37fe6a51 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 17:13:25 +0530 Subject: [PATCH 22/26] Clarify LiveRamp config and consent corrections --- .../2026-08-21-liveramp-integration-design.md | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md index d87b027d6..76d3d7a12 100644 --- a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -312,22 +312,33 @@ APIs before calling `pbjs.processQueue()`: 1. Capture and bind the real `pbjs.setConfig` and, when present, `pbjs.mergeConfig` implementations. -2. Replace both public APIs with wrappers that only normalize calls containing - `userSync.userIds`. Other configuration calls pass through unchanged. -3. For a `userSync.userIds` call, preserve every non-`identityLink` entry, +2. Replace both public APIs with wrappers that normalize every call containing + a `userSync` object. Calls without `userSync` pass through unchanged. +3. For a call with an explicit `userSync.userIds`, preserve every + non-`identityLink` entry, remove all publisher-supplied `identityLink` entries, and append exactly one operator-managed entry. Preserve sibling `userSync` and top-level fields. -4. Read the already-effective `pbjs.getConfig('userSync.userIds')` value, +4. For a `pbjs.setConfig` call whose `userSync` object omits `userIds`, read the + effective `pbjs.getConfig('userSync.userIds')` value and carry that list into + the replacement `userSync` object before normalizing it. Pinned Prebid + replaces a top-level `userSync` value in `setConfig`; it does not merge the + replacement with the current value. This preservation step prevents an + otherwise unrelated update such as `userSync.syncDelay` from silently + deleting the managed entry and every publisher User ID entry. Apply the + same invariant to `mergeConfig`, even though its current implementation + deep-merges, so ownership does not depend on an undocumented closure detail. +5. During initial installation, read the already-effective + `pbjs.getConfig('userSync.userIds')` value, normalize its supported array/config shape, preserve its non-`identityLink` entries, append the managed entry, and apply that merged list synchronously through the captured function. This covers publisher configuration that ran after the external Prebid bundle loaded but before the deferred TSJS shim. An absent or malformed effective list degrades to an empty publisher list. Complete this step before processing any existing queue entries. -5. Call `pbjs.processQueue()`. Queued publisher `setConfig` and `mergeConfig` +6. Call `pbjs.processQueue()`. Queued publisher `setConfig` and `mergeConfig` calls flow through the wrappers, so a later queued `requestBids` observes the managed entry. -6. Keep the wrappers installed after queue processing so later publisher calls +7. Keep the wrappers installed after queue processing so later publisher calls through either public configuration API cannot silently replace or delete the operator-owned LiveRamp policy. Repeated TSJS installation must not stack wrappers. @@ -393,6 +404,19 @@ ingestion provide reuse on later requests. Equivalent GPP/US-state activity controls (`gppControl_usnat`, `gppControl_usstates`) remain unbundled; US opt-outs are still enforced only at the server's forwarding gate. +- Pinned Prebid's default `tcfControl` rules do not treat every denied purpose + identically. Purpose 1 plus the module's GVL vendor consent controls + IdentityLink device access, resolution, and storage. Purpose 2 controls bid + fetching. Purpose 3 has no standalone default `tcfControl` rule. Purpose 4 + controls user-provided-data activity. With the default + `eidsRequireP4Consent: false`, EID transmission is permitted when any Purpose + 2–10 has the required purpose/legal-interest and vendor basis; publishers may + opt into requiring Purpose 4 specifically. Therefore a Purpose 3 or Purpose + 4 denial alone does not establish that the LiveRamp vendor request or + `idl_env` write is blocked. Automated artifact tests must vary Purpose 1, + Purposes 3/4, and vendor 97 independently, and the operator guide must + describe these exact defaults rather than claiming that every denied purpose + blocks resolution. - LiveRamp envelope values are opaque identifiers. They must never appear in logs, public diagnostics, error bodies, or telemetry dimensions. - The implementation does not collect plaintext or hashed email and does not @@ -413,7 +437,9 @@ ingestion provide reuse on later requests. | Invalid Placement ID or unsafe bounds | Reject configuration at startup/validation time. | | `identityLinkIdSystem` missing from external bundle | Emit existing missing-module diagnostics; continue auctions without LiveRamp EID. | | LiveRamp network or recognition failure | Prebid module yields no EID; continue auction normally. | -| No consent or opted-out user | Forward no LiveRamp EID; continue auction normally. | +| TCF Purpose 1 or LiveRamp vendor consent denied | Default `tcfControl` blocks IdentityLink resolution/storage; continue auction normally. | +| TCF Purpose 3 or 4 denied alone | Default rules do not prove resolution/storage is blocked; publisher policy may add stricter rules. | +| US-state opt-out | Server forwarding gate drops the LiveRamp EID; browser activity controls remain a documented gap. | | Malformed LiveRamp EID | Existing client/server EID sanitizers drop it. | | Oversized `ts-eids` payload | Existing bounded cookie behavior truncates whole UID/source entries; no partial UID is written. | | EC/KV unavailable | Current-request EID can still reach `/auction`; persistence degrades without blocking the auction. | @@ -462,7 +488,12 @@ Add tests in - a publisher `identityLink` update through `setConfig` after `processQueue()` is normalized back to the operator-managed values; - repeated installation does not stack either configuration wrapper; -- configuration calls unrelated to `userSync.userIds` pass through unchanged; +- configuration calls without `userSync` pass through unchanged; +- a queued or late `setConfig` call that replaces `userSync` without an + explicit `userIds` list preserves the effective publisher entries and the + single managed `identityLink` entry; +- a queued or late `mergeConfig` call whose `userSync` object omits `userIds` + preserves the same effective list; - missing `identityLinkIdSystem` appears in existing diagnostics; - `getUserIdsAsEids()` output for `liveramp.com` enters the current auction; - malformed and empty envelope values are dropped; @@ -478,6 +509,13 @@ Extend external bundle tests to prove: - explicitly selecting it stamps the module into the manifest; - selecting a bundle without it produces a deterministic diagnostic when LiveRamp configuration is present. +- a generated-real-bundle case that denies only Purpose 1 while granting + Purposes 3/4 and vendor 97 produces no LiveRamp request and no `idl_env`; +- a separate case that denies only vendor 97 while granting Purposes 1/3/4 + produces no LiveRamp request and no `idl_env`; +- separate cases that deny only Purpose 3 or only Purpose 4 while granting + Purpose 1 and vendor 97 still produce one LiveRamp request and write + `idl_env` under pinned Prebid's default rules. ### 11.4 Rust auction/EC regression tests From fe9c1eeb5d8650a2d36674c9e1e19fb8dd7a4e81 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 17:23:04 +0530 Subject: [PATCH 23/26] Plan LiveRamp review corrections --- .../plans/2026-08-21-liveramp-integration.md | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) diff --git a/docs/superpowers/plans/2026-08-21-liveramp-integration.md b/docs/superpowers/plans/2026-08-21-liveramp-integration.md index 460386242..97fbdc3e1 100644 --- a/docs/superpowers/plans/2026-08-21-liveramp-integration.md +++ b/docs/superpowers/plans/2026-08-21-liveramp-integration.md @@ -940,3 +940,310 @@ git commit -m "fix: address LiveRamp verification findings" ``` Do not create an empty verification commit. + +## Correction tasks added after PR #1054 review (2026-08-24) + +These tasks implement the reviewed correction in the specification. Complete +them in order and preserve the red/green evidence for the managed-config bug. + +## Task 6: Preserve managed IDs across partial `userSync` replacement + +**Files:** + +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts:985` +- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs:179` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts:228-273` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts:1169-1216` + +- [ ] **Step 1: Replace the unsafe pass-through unit test with failing ownership regressions** + +Change the current `passes unrelated publisher configuration through by +reference` test so only a configuration without `userSync` must retain object +identity. Add queued and late cases for both public APIs. Each case must arrange +`pbjs.getConfig('userSync.userIds')` to return a publisher entry plus the +managed entry, invoke a partial update such as: + +```ts +mockPbjs.setConfig({ + priceGranularity: 'medium', + userSync: { syncDelay: 50 }, +}) +``` + +and assert that the captured call contains: + +```ts +expect.objectContaining({ + priceGranularity: 'medium', + userSync: { + syncDelay: 50, + userIds: [{ name: 'sharedId' }, EXPECTED_IDENTITY_LINK], + }, +}) +``` + +For `mergeConfig`, assert the same invariant without depending on its current +internal deep-merge implementation. Its late-call case must change the +`mockGetConfig` result after installation—for example from `sharedId` to +`id5Id`—before invoking the partial update, then assert that the newly effective +`id5Id` survives. This proves the wrapper reads at call time instead of caching +the installation-time list. Keep the existing explicit-`userIds`, idempotence, +throwing-accessor, and missing-`getConfig` cases. + +- [ ] **Step 2: Add a failing real-artifact replacement regression** + +After the existing real `mergeConfig` assertions, call: + +```js +pageWindow.pbjs.setConfig({ userSync: { syncDelay: 50 } }) +``` + +Assert that `getConfig('userSync.userIds')` still contains `sharedId` and exactly +one managed `identityLink`, and that `getConfig('userSync.syncDelay')` is `50`. +This test must use the generated Prebid bundle and generated TSJS shim, not a +mock of `setConfig`. + +- [ ] **Step 3: Run the focused tests and verify the ownership regression is red** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts test/prebid-artifact-integration.test.mjs +``` + +Expected: the new partial-`userSync` assertions fail because the managed and +publisher User ID entries disappear. Existing tests must continue to execute. + +- [ ] **Step 4: Implement the minimal normalization correction** + +Refactor the managed helper so it distinguishes these cases: + +```ts +// No userSync object: return the publisher object unchanged. +// Explicit userSync.userIds: normalize that supplied list. +// userSync without userIds: read the effective list through bound getConfig, +// carry it into the replacement object, then normalize it. +``` + +Read `getConfig('userSync.userIds')` at call time so late publisher changes are +preserved. If `getConfig` is unavailable and `userIds` is omitted, retain the +existing fail-open behavior rather than fabricating an empty publisher list. +Build a fresh managed entry for every normalization and continue catching +throwing page-owned accessors. + +- [ ] **Step 5: Re-run focused tests and verify green** + +Run the Step 3 command again. + +Expected: all focused unit and real-artifact tests pass with no unhandled +errors. + +- [ ] **Step 6: Commit the ownership correction** + +```bash +git add \ + crates/trusted-server-js/lib/src/integrations/prebid/index.ts \ + crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts \ + crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +git commit -m "Preserve managed LiveRamp IDs across userSync updates" +``` + +## Task 7: Characterize exact default TCF enforcement + +**Files:** + +- Modify: `crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs:103-225` +- Modify: `docs/guide/integrations/prebid.md:134-141` +- Modify: `docs/guide/integrations/prebid.md:518-524` +- Modify: `docs/guide/integrations/prebid.md:578-588` + +- [ ] **Step 1: Replace the combined consent boolean with independent grants** + +Change the fixture API to accept explicit grants with safe defaults: + +```js +function tcData({ + purpose1 = true, + purpose3 = true, + purpose4 = true, + vendor97 = true, +} = {}) { + return { + // existing CMP fields + purpose: { + consents: { 1: purpose1, 3: purpose3, 4: purpose4 }, + legitimateInterests: {}, + }, + vendor: { + consents: { [LIVE_RAMP_GVL_VENDOR_ID]: vendor97 }, + legitimateInterests: {}, + }, + } +} +``` + +Pass this object through `runGdprPage` without combining the grants. + +- [ ] **Step 2: Add four independent artifact cases plus the granted baseline** + +Assert the exact pinned defaults: + +1. Purpose 1 denied alone: no LiveRamp request, no `idl_env`, no retry cookie. +2. Vendor 97 denied alone: no LiveRamp request, no `idl_env`, no retry cookie. +3. Purpose 3 denied alone: one LiveRamp request and `idl_env` written. +4. Purpose 4 denied alone: one LiveRamp request and `idl_env` written. +5. All relevant grants present: one LiveRamp request and `idl_env` written. + +- [ ] **Step 3: Run the consent artifact suite** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/prebid-consent-enforcement.test.mjs +``` + +Expected: all five behavioral cases and the module-presence assertion pass +against the real generated artifacts. If a case differs, inspect pinned Prebid +before changing the expected policy. + +- [ ] **Step 4: Correct the operator-facing consent claims** + +Document that default client-side resolution/storage is blocked by Purpose 1 +and LiveRamp vendor consent. State that Purpose 3 has no standalone default +rule, Purpose 4 controls UFPD, and default EID transmission accepts qualifying +purpose/vendor basis from any Purpose 2–10 unless the publisher enables +`eidsRequireP4Consent`. Preserve the existing explicit GPP/US-state limitation. + +- [ ] **Step 5: Format and verify the focused documentation** + +```bash +cd docs +npx prettier --write guide/integrations/prebid.md +npm run format +``` + +Expected: the guide is formatted and makes no broader enforcement claim than +the artifact matrix proves. + +- [ ] **Step 6: Commit the consent characterization** + +```bash +git add \ + crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs \ + docs/guide/integrations/prebid.md +git commit -m "Clarify LiveRamp TCF enforcement defaults" +``` + +## Task 8: Verify and refresh PR #1054 + +**Files:** + +- Verify: all PR files +- Update externally: PR #1054 description + +- [ ] **Step 1: Run TypeScript tests, build, and formatting** + +```bash +cd crates/trusted-server-js/lib +npx vitest run +node build-all.mjs +npm run lint +npm run format +``` + +Expected: every command exits 0. + +- [ ] **Step 2: Run repository Rust and documentation gates** + +```bash +cd /Users/prk-jr/Desktop/opensource/rust/trusted-server +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo clippy-cli +cargo clippy-codegen +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cd docs && npm run format +``` + +Expected: all commands exit 0. Do not use bare `cargo test --workspace`. + +- [ ] **Step 3: Inspect the final branch** + +```bash +git diff --check +git status --short +git diff origin/main...HEAD --stat +git log origin/main..HEAD --oneline +``` + +Expected: no uncommitted source changes and only intentional LiveRamp commits. + +- [ ] **Step 4: Request final code review** + +Review the entire `origin/main...HEAD` diff with special attention to the +partial-`userSync` real-artifact case and the independent consent matrix. Fix +all Critical or Important findings and repeat affected gates. + +- [ ] **Step 5: Push and update the draft PR description** + +Push without force. Create `/tmp/pr-1054-body.md` with the repository PR +template and these exact sections: + +- Summary: managed RampID configuration, opaque `liveramp.com` EID transport, + exact TCF default behavior, and ATS Direct exclusion. +- Closes: `Closes #355`. +- Status: `Code complete; live LiveRamp validation pending +IABTechLab/uid2-optout#385.` +- Changes table containing every one of these final diff paths and no removed + `crates/trusted-server-core/src/auction/endpoints.rs` row: + - `.cargo/config.toml` + - `CLAUDE.md` + - `crates/trusted-server-cli/src/prebid_bundle.rs` + - `crates/trusted-server-core/src/consent/mod.rs` + - `crates/trusted-server-core/src/ec/prebid_eids.rs` + - `crates/trusted-server-core/src/integrations/prebid.rs` + - `crates/trusted-server-js/lib/build-prebid-external.mjs` + - `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` + - `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` + - `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + - `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` + - `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` + - `crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs` + - `docs/guide/configuration.md` + - `docs/guide/integrations/prebid.md` + - `docs/superpowers/plans/2026-08-21-liveramp-integration.md` + - `docs/superpowers/specs/2026-08-21-liveramp-integration-design.md` + - `trusted-server.example.toml` +- Test plan: check every command completed in Steps 1–2; leave only live + credential validation unchecked. +- Hardening note: no config-derived regex or pattern compilation was added; + invalid enabled LiveRamp config fails typed validation. + +Verify the enumerated paths against +`git diff --name-only origin/main...HEAD` before writing the file. Apply the +body exactly with: + +```bash +git push origin issue-355-liveramp-integration +gh pr edit 1054 \ + --repo IABTechLab/trusted-server \ + --title "Add managed LiveRamp RampID integration" \ + --body-file /tmp/pr-1054-body.md +gh pr view 1054 \ + --repo IABTechLab/trusted-server \ + --json url,isDraft,headRefOid,body,statusCheckRollup +``` + +Do not mark the PR ready for review automatically; report the final readiness +assessment to the user first. From f293f718753c3004c21092547e83b224de5c62ae Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 18:13:17 +0530 Subject: [PATCH 24/26] Clarify LiveRamp consent and config behavior --- .../test/prebid-artifact-integration.test.mjs | 35 ++++++ .../test/prebid-consent-enforcement.test.mjs | 40 +++++-- docs/guide/integrations/prebid.md | 52 +++++---- .../plans/2026-08-21-liveramp-integration.md | 105 ++++-------------- .../2026-08-21-liveramp-integration-design.md | 25 ++--- 5 files changed, 128 insertions(+), 129 deletions(-) diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index e4c5e484b..25d7a88ee 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -144,6 +144,10 @@ describe('external bundle + served shim evaluated together', () => { }; pageWindow.eval(bundleCode); + pageWindow.pbjs.setConfig({ + userSync: { userIds: [{ name: 'sharedId' }] }, + }); + pageWindow.pbjs.setConfig({ userSync: { syncDelay: 41 } }); expect(typeof pageWindow.pbjs.requestBids).toBe('function'); expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); @@ -168,6 +172,7 @@ describe('external bundle + served shim evaluated together', () => { expect(pageWindow.pbjs.getConfig('userSync.userIds')).toEqual( expect.arrayContaining([ + expect.objectContaining({ name: 'sharedId' }), expect.objectContaining({ name: 'identityLink', params: { pid: '999', notUse3P: false }, @@ -176,6 +181,22 @@ describe('external bundle + served shim evaluated together', () => { ]) ); + // Characterize the pinned Prebid artifact: partial userSync updates retain + // its effective User ID list, including the operator-managed entry. + pageWindow.pbjs.setConfig({ userSync: { syncDelay: 50 } }); + + const userIdsAfterPartialUpdate = pageWindow.pbjs.getConfig('userSync.userIds'); + expect(userIdsAfterPartialUpdate.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), + ]); + expect(userIdsAfterPartialUpdate).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) + ); + expect(pageWindow.pbjs.getConfig('userSync.syncDelay')).toBe(50); + // Exercise the real Prebid mergeConfig implementation. It closes over // Prebid's internal setConfig, so the shim must guard mergeConfig itself // to prevent a publisher-owned duplicate from bypassing the setConfig guard. @@ -199,6 +220,20 @@ describe('external bundle + served shim evaluated together', () => { expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) ); + pageWindow.pbjs.mergeConfig({ userSync: { syncDelay: 75 } }); + + const userIdsAfterPartialMerge = pageWindow.pbjs.getConfig('userSync.userIds'); + expect(userIdsAfterPartialMerge.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), + ]); + expect(userIdsAfterPartialMerge).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) + ); + expect(pageWindow.pbjs.getConfig('userSync.syncDelay')).toBe(75); + // A second evaluation (double script inclusion, or a legacy bundle that // still carries a baked-in shim running after this one) must be a no-op. pageWindow.eval(shimCode); diff --git a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs index a5a78be12..8f42528e4 100644 --- a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs @@ -103,7 +103,7 @@ afterAll(() => { // `tcfControl` reads the CMP's structured `vendorData`, not the encoded string, // so the purpose and vendor grants below are what the rules actually evaluate. // The string only has to be present and non-empty. -function tcData(granted) { +function tcData({ purpose1 = true, purpose3 = true, purpose4 = true, vendor97 = true } = {}) { return { gdprApplies: true, tcString: 'CPexampleTCStringForTests', @@ -111,11 +111,11 @@ function tcData(granted) { cmpStatus: 'loaded', apiVersion: '2', purpose: { - consents: { 1: granted, 3: granted, 4: granted }, + consents: { 1: purpose1, 3: purpose3, 4: purpose4 }, legitimateInterests: {}, }, vendor: { - consents: { [LIVE_RAMP_GVL_VENDOR_ID]: granted }, + consents: { [LIVE_RAMP_GVL_VENDOR_ID]: vendor97 }, legitimateInterests: {}, }, publisher: { restrictions: {} }, @@ -125,11 +125,11 @@ function tcData(granted) { /** * Evaluates both artifacts on a GDPR page whose CMP grants or denies the - * purposes IdentityLink needs, then runs one auction. + * purpose and vendor grants, then runs one auction. * * @returns the URLs the page requested and the cookies it managed to set. */ -async function runGdprPage(granted) { +async function runGdprPage(grants = {}) { const dom = new JSDOM('', { url: 'https://pub.example.com/article', runScripts: 'outside-only', @@ -160,7 +160,7 @@ async function runGdprPage(granted) { pageWindow.isSecureContext = true; } - const consentData = tcData(granted); + const consentData = tcData(grants); pageWindow.__tcfapi = (command, _version, callback) => { if (command === 'addEventListener' || command === 'getTCData') { callback(consentData, true); @@ -209,15 +209,37 @@ describe('external bundle TCF enforcement', () => { }); it('blocks IdentityLink storage and vendor calls when Purpose 1 is denied', async () => { - const { requestedUrls, cookies } = await runGdprPage(false); + const { requestedUrls, cookies } = await runGdprPage({ purpose1: false }); expect(envelopeRequests(requestedUrls)).toEqual([]); expect(cookies).not.toContain(LIVE_RAMP_STORAGE_NAME); expect(cookies).not.toContain('_lr_retry_request'); }); - it('still resolves IdentityLink when the required purposes are granted', async () => { - const { requestedUrls, cookies } = await runGdprPage(true); + it('blocks IdentityLink storage and vendor calls when vendor 97 is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage({ vendor97: false }); + + expect(envelopeRequests(requestedUrls)).toEqual([]); + expect(cookies).not.toContain(LIVE_RAMP_STORAGE_NAME); + expect(cookies).not.toContain('_lr_retry_request'); + }); + + it('still resolves IdentityLink when Purpose 3 alone is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage({ purpose3: false }); + + expect(envelopeRequests(requestedUrls)).toHaveLength(1); + expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); + }); + + it('still resolves IdentityLink when Purpose 4 alone is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage({ purpose4: false }); + + expect(envelopeRequests(requestedUrls)).toHaveLength(1); + expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); + }); + + it('resolves IdentityLink when all relevant grants are present', async () => { + const { requestedUrls, cookies } = await runGdprPage(); expect(envelopeRequests(requestedUrls)).toHaveLength(1); expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 54ae1f6e0..79bf20f62 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -131,14 +131,13 @@ regenerating and re-uploading the bundle (and pushing the updated rollout. The shim refuses to install twice on one page via the `window.__tsjsPrebidShimInstalled` sentinel. -The consent modules include Prebid's `tcfControl`, so a regenerated bundle -**enforces** the TCF signal it collects rather than only reporting it. Under -GDPR, activities whose purposes the CMP denies — device access, user sync, EID -enrichment and transmission, bid requests — are blocked client-side for every -bidder and User ID module, not only for LiveRamp. Publishers whose CMP does not -actually grant the purposes their current setup relies on will see EU activity -drop once they regenerate, so validate a regenerated bundle against a live CMP -before rolling it out broadly. +The consent modules include Prebid's `tcfControl`, so a regenerated bundle can +enforce the TCF signal it collects rather than only reporting it. Its default +rules are activity-specific. For the managed IdentityLink entry, Purpose 1 and +LiveRamp vendor consent gate browser resolution and storage. Purpose 3 has no +standalone default rule, while Purpose 4 controls user-provided-data activity +rather than IdentityLink resolution. Validate a regenerated bundle against a +live CMP before rolling it out broadly. ## Debug Mode @@ -517,11 +516,19 @@ list to take the generator's default preset, which includes it. The generated bundle carries Prebid's `tcfControl` module alongside the `consentManagement*` modules. That pairing is what makes the TCF signal -enforceable: `consentManagement*` only retrieves the consent string, while -`tcfControl` registers the activity controls (device access, user sync, EID -enrichment and transmission) that act on it. A bundle built without it collects -consent and then ignores it — every User ID submodule would still write browser -storage and still call its vendor endpoint on a denied purpose. +enforceable: `consentManagement*` retrieves the consent data, while `tcfControl` +registers activity controls that act on it. Under pinned Prebid's defaults, +Purpose 1 and LiveRamp's GVL vendor consent (vendor 97) gate IdentityLink +resolution and storage. Purpose 3 has no standalone default rule. Purpose 4 +controls user-provided-data activity, but denying it alone does not block +IdentityLink resolution or storage. + +Default EID transmission accepts a qualifying purpose and vendor basis from any +of Purposes 2–10. Publishers can require Purpose 4 specifically by enabling +Prebid's `eidsRequireP4Consent` setting. These are the generated bundle's TCF +defaults; equivalent GPP/US-state browser activity-control modules are not +bundled, so US-state opt-outs remain enforced at Trusted Server's forwarding +gate. When enabled, Trusted Server owns one deterministic `identityLink` entry in `userSync.userIds` for publisher configuration applied through the public @@ -577,15 +584,16 @@ server-to-server ATS API. ### Degraded behavior -| Condition | Result | -| ------------------------------------------------- | ---------------------------------------------------------------------- | -| Required TCF purposes are denied | `tcfControl` blocks resolution: no vendor call, no `idl_env`, no EID | -| The user opts out under a US state signal | No LiveRamp EID is forwarded; the auction continues | -| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | -| LiveRamp network resolution fails | The current auction continues without RampID | -| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | -| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | -| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | +| Condition | Result | +| -------------------------------------------------- | ----------------------------------------------------------------------- | +| TCF Purpose 1 or LiveRamp vendor consent is denied | Default `tcfControl` blocks IdentityLink resolution and storage | +| TCF Purpose 3 or 4 alone is denied | Resolution/storage continues under defaults; publisher rules may differ | +| The user opts out under a US state signal | No LiveRamp EID is forwarded; the auction continues | +| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | +| LiveRamp network resolution fails | The current auction continues without RampID | +| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | +| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | +| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | ### Credential-based validation diff --git a/docs/superpowers/plans/2026-08-21-liveramp-integration.md b/docs/superpowers/plans/2026-08-21-liveramp-integration.md index 97fbdc3e1..bacca368b 100644 --- a/docs/superpowers/plans/2026-08-21-liveramp-integration.md +++ b/docs/superpowers/plans/2026-08-21-liveramp-integration.md @@ -944,55 +944,17 @@ Do not create an empty verification commit. ## Correction tasks added after PR #1054 review (2026-08-24) These tasks implement the reviewed correction in the specification. Complete -them in order and preserve the red/green evidence for the managed-config bug. +them in order and preserve artifact-level evidence for configuration behavior. -## Task 6: Preserve managed IDs across partial `userSync` replacement +## Task 6: Characterize partial `userSync` updates **Files:** -- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts:985` - Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs:179` -- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts:228-273` -- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts:1169-1216` -- [ ] **Step 1: Replace the unsafe pass-through unit test with failing ownership regressions** +- [x] **Step 1: Test the review premise against the generated artifact** -Change the current `passes unrelated publisher configuration through by -reference` test so only a configuration without `userSync` must retain object -identity. Add queued and late cases for both public APIs. Each case must arrange -`pbjs.getConfig('userSync.userIds')` to return a publisher entry plus the -managed entry, invoke a partial update such as: - -```ts -mockPbjs.setConfig({ - priceGranularity: 'medium', - userSync: { syncDelay: 50 }, -}) -``` - -and assert that the captured call contains: - -```ts -expect.objectContaining({ - priceGranularity: 'medium', - userSync: { - syncDelay: 50, - userIds: [{ name: 'sharedId' }, EXPECTED_IDENTITY_LINK], - }, -}) -``` - -For `mergeConfig`, assert the same invariant without depending on its current -internal deep-merge implementation. Its late-call case must change the -`mockGetConfig` result after installation—for example from `sharedId` to -`id5Id`—before invoking the partial update, then assert that the newly effective -`id5Id` survives. This proves the wrapper reads at call time instead of caching -the installation-time list. Keep the existing explicit-`userIds`, idempotence, -throwing-accessor, and missing-`getConfig` cases. - -- [ ] **Step 2: Add a failing real-artifact replacement regression** - -After the existing real `mergeConfig` assertions, call: +Preconfigure a publisher `sharedId`, install the shim, then call: ```js pageWindow.pbjs.setConfig({ userSync: { syncDelay: 50 } }) @@ -1000,10 +962,10 @@ pageWindow.pbjs.setConfig({ userSync: { syncDelay: 50 } }) Assert that `getConfig('userSync.userIds')` still contains `sharedId` and exactly one managed `identityLink`, and that `getConfig('userSync.syncDelay')` is `50`. -This test must use the generated Prebid bundle and generated TSJS shim, not a -mock of `setConfig`. +This test uses the generated Prebid bundle and generated TSJS shim, not a mock +of `setConfig`. -- [ ] **Step 3: Run the focused tests and verify the ownership regression is red** +- [x] **Step 2: Verify actual pinned behavior** Run: @@ -1012,41 +974,18 @@ cd crates/trusted-server-js/lib npx vitest run test/integrations/prebid/index.test.ts test/prebid-artifact-integration.test.mjs ``` -Expected: the new partial-`userSync` assertions fail because the managed and -publisher User ID entries disappear. Existing tests must continue to execute. - -- [ ] **Step 4: Implement the minimal normalization correction** - -Refactor the managed helper so it distinguishes these cases: - -```ts -// No userSync object: return the publisher object unchanged. -// Explicit userSync.userIds: normalize that supplied list. -// userSync without userIds: read the effective list through bound getConfig, -// carry it into the replacement object, then normalize it. -``` - -Read `getConfig('userSync.userIds')` at call time so late publisher changes are -preserved. If `getConfig` is unavailable and `userIds` is omitted, retain the -existing fail-open behavior rather than fabricating an empty publisher list. -Build a fresh managed entry for every normalization and continue catching -throwing page-owned accessors. - -- [ ] **Step 5: Re-run focused tests and verify green** - -Run the Step 3 command again. - -Expected: all focused unit and real-artifact tests pass with no unhandled -errors. +Observed: all focused tests pass. The pinned Prebid artifact retains its +effective `userIds` list across the partial update. Mock-only tests that expected +the shim to inject `userIds` into the forwarded argument were discarded because +the mock does not model the shipped artifact's effective configuration behavior. +No production wrapper change is required. -- [ ] **Step 6: Commit the ownership correction** +- [x] **Step 3: Commit the artifact characterization** ```bash git add \ - crates/trusted-server-js/lib/src/integrations/prebid/index.ts \ - crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts \ crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs -git commit -m "Preserve managed LiveRamp IDs across userSync updates" +git commit -m "Characterize partial Prebid userSync updates" ``` ## Task 7: Characterize exact default TCF enforcement @@ -1058,7 +997,7 @@ git commit -m "Preserve managed LiveRamp IDs across userSync updates" - Modify: `docs/guide/integrations/prebid.md:518-524` - Modify: `docs/guide/integrations/prebid.md:578-588` -- [ ] **Step 1: Replace the combined consent boolean with independent grants** +- [x] **Step 1: Replace the combined consent boolean with independent grants** Change the fixture API to accept explicit grants with safe defaults: @@ -1085,7 +1024,7 @@ function tcData({ Pass this object through `runGdprPage` without combining the grants. -- [ ] **Step 2: Add four independent artifact cases plus the granted baseline** +- [x] **Step 2: Add four independent artifact cases plus the granted baseline** Assert the exact pinned defaults: @@ -1095,7 +1034,7 @@ Assert the exact pinned defaults: 4. Purpose 4 denied alone: one LiveRamp request and `idl_env` written. 5. All relevant grants present: one LiveRamp request and `idl_env` written. -- [ ] **Step 3: Run the consent artifact suite** +- [x] **Step 3: Run the consent artifact suite** Run: @@ -1108,7 +1047,7 @@ Expected: all five behavioral cases and the module-presence assertion pass against the real generated artifacts. If a case differs, inspect pinned Prebid before changing the expected policy. -- [ ] **Step 4: Correct the operator-facing consent claims** +- [x] **Step 4: Correct the operator-facing consent claims** Document that default client-side resolution/storage is blocked by Purpose 1 and LiveRamp vendor consent. State that Purpose 3 has no standalone default @@ -1116,7 +1055,7 @@ rule, Purpose 4 controls UFPD, and default EID transmission accepts qualifying purpose/vendor basis from any Purpose 2–10 unless the publisher enables `eidsRequireP4Consent`. Preserve the existing explicit GPP/US-state limitation. -- [ ] **Step 5: Format and verify the focused documentation** +- [x] **Step 5: Format and verify the focused documentation** ```bash cd docs @@ -1127,7 +1066,7 @@ npm run format Expected: the guide is formatted and makes no broader enforcement claim than the artifact matrix proves. -- [ ] **Step 6: Commit the consent characterization** +- [x] **Step 6: Commit the consent characterization** ```bash git add \ @@ -1143,7 +1082,7 @@ git commit -m "Clarify LiveRamp TCF enforcement defaults" - Verify: all PR files - Update externally: PR #1054 description -- [ ] **Step 1: Run TypeScript tests, build, and formatting** +- [x] **Step 1: Run TypeScript tests, build, and formatting** ```bash cd crates/trusted-server-js/lib @@ -1155,7 +1094,7 @@ npm run format Expected: every command exits 0. -- [ ] **Step 2: Run repository Rust and documentation gates** +- [x] **Step 2: Run repository Rust and documentation gates** ```bash cd /Users/prk-jr/Desktop/opensource/rust/trusted-server diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md index 76d3d7a12..44a5308a1 100644 --- a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -318,15 +318,12 @@ APIs before calling `pbjs.processQueue()`: non-`identityLink` entry, remove all publisher-supplied `identityLink` entries, and append exactly one operator-managed entry. Preserve sibling `userSync` and top-level fields. -4. For a `pbjs.setConfig` call whose `userSync` object omits `userIds`, read the - effective `pbjs.getConfig('userSync.userIds')` value and carry that list into - the replacement `userSync` object before normalizing it. Pinned Prebid - replaces a top-level `userSync` value in `setConfig`; it does not merge the - replacement with the current value. This preservation step prevents an - otherwise unrelated update such as `userSync.syncDelay` from silently - deleting the managed entry and every publisher User ID entry. Apply the - same invariant to `mergeConfig`, even though its current implementation - deep-merges, so ownership does not depend on an undocumented closure detail. +4. Calls whose `userSync` object omits `userIds` pass through unchanged. The + pinned generated Prebid artifact retains its effective `userIds` defaults + across partial `setConfig` and `mergeConfig` updates, so injecting a copied + list in the shim would duplicate Prebid behavior and make the wrapper depend + on a mocked configuration model that does not match the shipped artifact. + A real-artifact characterization test protects this pinned behavior. 5. During initial installation, read the already-effective `pbjs.getConfig('userSync.userIds')` value, normalize its supported array/config shape, preserve its non-`identityLink` @@ -488,12 +485,8 @@ Add tests in - a publisher `identityLink` update through `setConfig` after `processQueue()` is normalized back to the operator-managed values; - repeated installation does not stack either configuration wrapper; -- configuration calls without `userSync` pass through unchanged; -- a queued or late `setConfig` call that replaces `userSync` without an - explicit `userIds` list preserves the effective publisher entries and the - single managed `identityLink` entry; -- a queued or late `mergeConfig` call whose `userSync` object omits `userIds` - preserves the same effective list; +- configuration calls without an explicit `userIds` list pass through + unchanged; - missing `identityLinkIdSystem` appears in existing diagnostics; - `getUserIdsAsEids()` output for `liveramp.com` enters the current auction; - malformed and empty envelope values are dropped; @@ -516,6 +509,8 @@ Extend external bundle tests to prove: - separate cases that deny only Purpose 3 or only Purpose 4 while granting Purpose 1 and vendor 97 still produce one LiveRamp request and write `idl_env` under pinned Prebid's default rules. +- a generated-real-bundle case proves a partial `userSync` update retains the + publisher entry and exactly one managed `identityLink` entry. ### 11.4 Rust auction/EC regression tests From 8b709645e8bf7b6f5af2b9bc8d39c4c08427f797 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 18:20:35 +0530 Subject: [PATCH 25/26] Record LiveRamp review completion --- docs/superpowers/plans/2026-08-21-liveramp-integration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-21-liveramp-integration.md b/docs/superpowers/plans/2026-08-21-liveramp-integration.md index bacca368b..f32017ec4 100644 --- a/docs/superpowers/plans/2026-08-21-liveramp-integration.md +++ b/docs/superpowers/plans/2026-08-21-liveramp-integration.md @@ -1117,7 +1117,7 @@ cd docs && npm run format Expected: all commands exit 0. Do not use bare `cargo test --workspace`. -- [ ] **Step 3: Inspect the final branch** +- [x] **Step 3: Inspect the final branch** ```bash git diff --check @@ -1128,7 +1128,7 @@ git log origin/main..HEAD --oneline Expected: no uncommitted source changes and only intentional LiveRamp commits. -- [ ] **Step 4: Request final code review** +- [x] **Step 4: Request final code review** Review the entire `origin/main...HEAD` diff with special attention to the partial-`userSync` real-artifact case and the independent consent matrix. Fix From 1f9cae268ef5f8cb30733b44fb8ae573e9810882 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 18:21:47 +0530 Subject: [PATCH 26/26] Record LiveRamp PR refresh --- docs/superpowers/plans/2026-08-21-liveramp-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-21-liveramp-integration.md b/docs/superpowers/plans/2026-08-21-liveramp-integration.md index f32017ec4..c56887a81 100644 --- a/docs/superpowers/plans/2026-08-21-liveramp-integration.md +++ b/docs/superpowers/plans/2026-08-21-liveramp-integration.md @@ -1134,7 +1134,7 @@ Review the entire `origin/main...HEAD` diff with special attention to the partial-`userSync` real-artifact case and the independent consent matrix. Fix all Critical or Important findings and repeat affected gates. -- [ ] **Step 5: Push and update the draft PR description** +- [x] **Step 5: Push and update the draft PR description** Push without force. Create `/tmp/pr-1054-body.md` with the repository PR template and these exact sections: