Add managed LiveRamp RampID integration - #1054
Conversation
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.
`[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.
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.
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.
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.
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.
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.
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.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Requesting changes for two consent-enforcement regressions and one managed User ID validation issue.
Main split the Prebid configuration into a browser-only `PrebidIntegrationConfig` and a test-only `LegacyPrebidServerConfig`, so move `managed_user_ids` onto the live browser config and map it through the legacy conversion. Left on the legacy struct the field would have compiled but never reached a running service. Also un-gate the `HashSet` import now that managed User ID name validation runs in production builds, and update the script-breakout assertion for main's stronger JSON escaping, which escapes every `<` rather than only `</script>`.
TCF activation read `window.__tcfapi` once, but managed User ID seeding
ran regardless. A CMP that installed itself after the deferred shim left
managed modules seeded with Prebid's GDPR handler disabled, and the
module fired its vendor request with no TCF parameters. No later
reconfiguration can recall that request, so hold managed entries out of
every configuration Prebid sees until CMP discovery concludes: at once
when `__tcfapi` is already callable, on a watched late assignment, or at
the first `requestBids`, since a conforming CMP installs its stub before
vendor tags request bids.
Prebid 10.26 reads `consentManagement` as
`config.gdpr || config.usp || config.gpp ? config.gdpr : config`, so a
legacy top-level TCF configuration carries no namespace key. Recognizing
only an own `gdpr` property let the shim append `gdpr: { cmpApi: "iab" }`
to such an object, which flipped Prebid to the namespaced shape and
discarded the publisher's static consent. Mirror Prebid's own rule in
both ownership checks. Retirement then needs a matching path: a
`mergeConfig` deep merge carries `gdpr: { enabled: false }` forward and
would disable the TCF module entirely, so remove the namespace instead of
leaving it disabled.
`ts prebid bundle` accepted each managed name independently even when
several resolved to one module. The registry maps both `sharedId` and
`pubCommonId` to `sharedIdSystem`; Prebid registers one submodule for a
module's name and its aliases and selects the first matching entry, so
the second managed configuration was silently ignored. Reject duplicate
resolved module names before generation.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Review summary
Reviewed d269ff8f9e3c78e7f024f29cd349739723279f51 against 705a69505bf6bf1eb5b498a88606d1473fdc6ea1. The bundle validation and focused tests pass, but the production Rust injection path drops the new configuration, and the first-auction CMP cutoff still permits managed identity resolution before a late CMP can enforce denied consent.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Reviewed 66da8064d81f3ba9da969eb048857933743234c8. The late-CMP changes handle delayed API discovery, but the automatic TCF policy still fails open when a callable CMP stub does not return consent before Prebid's timeout.
Reclassified as a broader consent-timeout policy decision rather than an unconditional merge blocker; superseded by a follow-up discussion review.
There was a problem hiding this comment.
@prk-Jr the changes in prebid/index.ts seem to have privacy/demand implications that I'm not sure about so the following is worth calling out and perhaps needs product decision.
Consent-timeout policy worth discussing
I dismissed my earlier change request because this is better treated as a product/privacy policy decision than an unconditional implementation defect. The reproduction still stands: when __tcfapi is callable but never returns consent, Prebid's default 10-second timeout produces null consent with gdprApplies: false; the managed IdentityLink module can then call api.rlcdn.com without TCF parameters and write its identity cookies.
There are several defensible policies:
1. Leave the current behavior unchanged
Pros
- Preserves Prebid's standard timeout semantics.
- Avoids suppressing demand or analytics during a CMP outage.
- Requires no additional lifecycle/state handling.
Cons
- A stalled CMP is treated like GDPR does not apply, even though the callable API indicates that a CMP was expected.
- An operator-managed identity module may contact its vendor and write storage without an affirmative jurisdiction or consent result.
- This failure mode should be explicitly documented so operators understand the compliance tradeoff.
2. Set defaultGdprScope: true for the automatic TCF configuration
Pros
- Small, direct change that fails closed after the timeout.
- Responsive CMP behavior is unchanged:
gdprApplies: falsestill permits activity, while valid grants and denials are enforced normally. - Prevents managed identity resolution when consent state is unavailable.
Cons
- The blast radius is broader than managed User IDs.
tcfControlcan also block client-side bidders, analytics, EID transmission, user data, and other controlled Prebid activity. - A broken CMP could therefore reduce or eliminate monetization, including for non-EU users if the CMP never reports
gdprApplies: false. - The page remains restricted until a later CMP callback updates the consent handler.
3. Defer only managed User ID seeding until a terminal CMP result
Seed managed entries only after gdprApplies: false, eventStatus: 'tcloaded', or eventStatus: 'useractioncomplete'.
Pros
- Gives operator-managed IDs a fail-closed guarantee without changing Prebid's timeout policy for publisher bidders and analytics.
- Keeps the monetization impact limited to the feature being introduced here.
- A later valid CMP event can still activate the managed entries.
Cons
- Requires more state coordination than the one-line scope change, especially for synchronous callbacks, late publisher ownership, listener retirement, and recovery after the first auction.
- Managed IDs remain unavailable for the page lifetime if the CMP never reaches a terminal state.
- Needs generated-artifact coverage for timeout, late recovery, denial, and
gdprApplies: false.
4. Make the timeout posture operator-configurable
For example, expose a managed-ID consent policy selecting Prebid-default, global fail-closed, or managed-only deferral behavior.
Pros
- Makes the compliance/revenue decision explicit for each deployment.
- Supports operators with different jurisdiction and CMP guarantees.
Cons
- Expands configuration and documentation substantially for a narrow feature.
- A permissive default can still surprise operators; a restrictive default can still surprise them operationally.
- Adds more combinations that require browser-artifact testing.
My preference would be option 3 if managed identities need a stronger guarantee without changing the rest of the publisher's Prebid failure behavior. Leaving the current behavior can also be intentional, but the timeout semantics and resulting vendor/storage activity should then be documented explicitly.
aram356
left a comment
There was a problem hiding this comment.
Summary
High-quality implementation of vendor-neutral managed Prebid User ID modules with LiveRamp RampID as the worked example. The fail-closed CMP-discovery machinery is carefully built, and the test depth (real-bundle TCF purpose/vendor matrix, ownership-transfer races, hostile-proxy edge cases, fail-closed CLI registry validation) is unusually strong. One correctness gap in the ownership invariant drives the verdict; the remaining findings are non-blocking.
3 of the 5 inline comments below carry a one-click GitHub
suggestion(all scratch-verified against this head:cargo fmt+ all six adapter clippy aliases + all four adapter test aliases + parity suite for the Rust changes; prettier + full vitest suite (967/967) +node build-all.mjsfor the TypeScript change; verified in isolation and as a batch). Use Commit suggestion or Add suggestion to batch to apply them. The other two comments are observations with the reasoning in prose.
Blocking
🔧 wrench
- Case-variant publisher entry silently defeats managed User ID ownership — see inline at
crates/trusted-server-js/lib/src/integrations/prebid/index.ts:292
Non-blocking
♻️ refactor
- Duplicate-name validation should use Prebid's case-insensitive matching rule — see inline at
crates/trusted-server-core/src/integrations/prebid.rs:284
🤔 thinking
- Real RampID envelopes vs the 512-byte EC value cap — see inline at
crates/trusted-server-core/src/ec/prebid_eids.rs:804 watchForLateTcfApiaccessor is never dismantled on the publisher-config seeding path — see inline atcrates/trusted-server-js/lib/src/integrations/prebid/index.ts:1740
⛏ nitpick
- Garbled doc phrase "untrimmed-free ASCII token" — see inline at
crates/trusted-server-core/src/integrations/prebid.rs:227
CI Status
- browser integration tests: PASS
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- cargo check/build/test (spin native + wasm32-wasip1): PASS
- vitest: PASS
- CodeQL: PASS
- cargo test (ts CLI, native): PASS
- cargo test (axum native): PASS
- Analyze (javascript-typescript): PASS
- format-typescript: PASS (required)
- format-docs: PASS (required)
- cargo check (cloudflare native + wasm32-unknown-unknown): PASS
- Analyze (rust): PASS
- cargo test: PASS (required)
- cargo test (cross-adapter parity): PASS
- cargo fmt: PASS (required)
- prepare integration artifacts: PASS
- Analyze (actions): PASS
| const managedNames = new Set(managedUserIds.map((managed) => managed.name)); | ||
| const retained = configuredUserIdEntries(config.userSync.userIds).filter( | ||
| (entry) => !managedNames.has(entry.name) | ||
| ); |
There was a problem hiding this comment.
🔧 wrench — Case-variant publisher entry silently defeats managed User ID ownership.
Prebid matches userSync.userIds entry names to submodules case-insensitively and takes the first matching config entry (pinned prebid.js, modules/userId/index.ts:1069-1070: value?.toLowerCase() === query.toLowerCase() followed by configs.find(...)). This filter compares names case-sensitively, and retained publisher entries are placed before managed entries. So pbjs.setConfig({userSync: {userIds: [{name: 'IdentityLink', params: {pid: 'publisher-value'}}]}}) survives the filter, lands ahead of the managed identityLink entry, and Prebid's configs.find selects the publisher's entry for identityLinkIdSystem — the operator-managed configuration is silently ignored. That breaks the documented invariant that calls which add, remove, or replace a managed name are normalized back to the operator-managed values.
| const managedNames = new Set(managedUserIds.map((managed) => managed.name)); | |
| const retained = configuredUserIdEntries(config.userSync.userIds).filter( | |
| (entry) => !managedNames.has(entry.name) | |
| ); | |
| const managedNames = new Set(managedUserIds.map((managed) => managed.name.toLowerCase())); | |
| const retained = configuredUserIdEntries(config.userSync.userIds).filter( | |
| (entry) => !managedNames.has(entry.name.toLowerCase()) | |
| ); |
Scratch-verified: prettier check, full vitest suite (967/967, including the real-bundle ownership tests), and node build-all.mjs all pass with this applied. A regression test asserting that a case-variant publisher entry (IdentityLink) is filtered would pin this down; it lives outside this diff range, so it is left to you to add.
| /// Rejects a managed User ID list that names the same module twice. | ||
| /// | ||
| /// Prebid keys `userSync.userIds` by entry name, so two entries sharing a name | ||
| /// give one submodule two conflicting configurations with no defined winner. | ||
| fn validate_unique_managed_user_id_names( | ||
| entries: &[PrebidManagedUserIdConfig], | ||
| ) -> Result<(), ValidationError> { | ||
| let mut seen = HashSet::with_capacity(entries.len()); | ||
| let Some(duplicate) = entries | ||
| .iter() | ||
| .find(|entry| !seen.insert(entry.name.as_str())) |
There was a problem hiding this comment.
♻️ refactor — Duplicate-name validation should use Prebid's case-insensitive matching rule.
This validator uses exact string comparison, so name = "exampleId" and name = "exampleid" both pass core validation — yet both address the same Prebid submodule, and Prebid takes the first matching entry with no defined winner. The ts prebid bundle registry check catches the pair only when the operator runs the CLI (and its exact-match lookup rejects a case variant as "unknown" rather than as a duplicate); runtime config validation alone lets the pair through to the browser. The doc on PrebidIntegrationConfig.managed_user_ids already promises that no two names may resolve to the same submodule. Names are validated ASCII-only, so to_ascii_lowercase matches Prebid's rule exactly.
| /// Rejects a managed User ID list that names the same module twice. | |
| /// | |
| /// Prebid keys `userSync.userIds` by entry name, so two entries sharing a name | |
| /// give one submodule two conflicting configurations with no defined winner. | |
| fn validate_unique_managed_user_id_names( | |
| entries: &[PrebidManagedUserIdConfig], | |
| ) -> Result<(), ValidationError> { | |
| let mut seen = HashSet::with_capacity(entries.len()); | |
| let Some(duplicate) = entries | |
| .iter() | |
| .find(|entry| !seen.insert(entry.name.as_str())) | |
| /// Rejects a managed User ID list whose names address one module twice. | |
| /// | |
| /// Prebid matches `userSync.userIds` entry names to submodules | |
| /// case-insensitively and takes the first matching entry, so two entries whose | |
| /// names differ only by case give one submodule two conflicting configurations | |
| /// with no defined winner. | |
| fn validate_unique_managed_user_id_names( | |
| entries: &[PrebidManagedUserIdConfig], | |
| ) -> Result<(), ValidationError> { | |
| let mut seen = HashSet::with_capacity(entries.len()); | |
| let Some(duplicate) = entries | |
| .iter() | |
| .find(|entry| !seen.insert(entry.name.to_ascii_lowercase())) |
Scratch-verified against the full CI-equivalent gate: cargo fmt --all -- --check, all six adapter clippy aliases, cargo test-fastly / test-axum / test-cloudflare / test-spin, and the cross-adapter parity suite all pass with this applied. A managed_user_ids_reject_a_case_variant_module_name test would cover the new rejection path; it lives outside this suggestion range.
| } | ||
|
|
||
| #[test] | ||
| fn ingest_liveramp_eid_cookie_preserves_the_opaque_envelope() { |
There was a problem hiding this comment.
🤔 thinking — Real RampID envelopes vs the 512-byte EC value cap.
This test proves a 20-byte placeholder envelope survives ingestion, but MAX_UID_LENGTH is 512 bytes (kv_types.rs:35) and real ATS envelopes routinely run several hundred characters — some exceed 512. An oversized envelope is silently dropped at EC persistence (the auction path is unaffected), which is a degradation the docs' "Degraded behavior" table does not list. Two cheap follow-ups: measure a real envelope's length during the credential-gated live validation (one extra bullet in its step list), and/or add an "envelope exceeds the EC value cap" row to the degraded-behavior table.
| // Auctions may run before an asynchronous CMP arrives. Keep managed IDs | ||
| // deferred even when the property cannot be watched; later configuration | ||
| // and auction calls recheck discovery without treating absence as consent. | ||
| watchForLateTcfApi(trySeedManagedUserIds); |
There was a problem hiding this comment.
🤔 thinking — watchForLateTcfApi accessor is never dismantled on the publisher-config seeding path.
watchForLateTcfApi returns its settle restorer, but this call site discards it. When seeding later resolves through publisher consent configuration (no CMP ever appearing), the get/set accessor pair stays installed on window.__tcfapi for the life of the page. Behavior is preserved for readers, and a late CMP assignment still settles correctly, but the accessor-typed property is observable (for example via Object.getOwnPropertyDescriptor) and could surprise CMP stubs or diagnostics that expect a plain data property. Worth either capturing the settle function and calling it once seeding concludes through another path, or a short comment stating the residue is accepted.
| /// entry name and on a storage key: a non-empty, untrimmed-free ASCII token. | ||
| /// Anything narrower would encode one vendor's rules into core. |
There was a problem hiding this comment.
⛏ nitpick — Garbled doc phrase "untrimmed-free ASCII token".
| /// entry name and on a storage key: a non-empty, untrimmed-free ASCII token. | |
| /// Anything narrower would encode one vendor's rules into core. | |
| /// entry name and on a storage key: a non-empty ASCII token with no | |
| /// surrounding whitespace. Anything narrower would encode one vendor's rules | |
| /// into core. |
Scratch-verified: cargo fmt --all -- --check and cargo clippy-fastly pass (pure comment change). For awareness only: the value.trim() == value clause below is technically redundant with the byte-class check, since whitespace is outside the allowed byte set; fine to keep as belt-and-braces.
Summary
liveramp.comEIDs through the existing auction, consent, edge-cookie, and EC/KV paths, with generated-artifact coverage for exact default TCF behavior.Status
Code complete; live LiveRamp validation pending IABTechLab/uid2-optout#385.
Changes
.cargo/config.tomlCLAUDE.mdcrates/trusted-server-cli/src/prebid_bundle.rsidentityLinkIdSystem.crates/trusted-server-core/src/consent/mod.rscrates/trusted-server-core/src/ec/prebid_eids.rscrates/trusted-server-core/src/integrations/prebid.rscrates/trusted-server-js/lib/build-prebid-external.mjstcfControlwith generated Prebid artifacts.crates/trusted-server-js/lib/src/integrations/prebid/index.tscrates/trusted-server-js/lib/test/build-prebid-external.test.mjscrates/trusted-server-js/lib/test/integrations/prebid/index.test.tscrates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.tscrates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjsuserSyncupdates and auction EID output.crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjsdocs/guide/configuration.mddocs/guide/integrations/prebid.mddocs/superpowers/plans/2026-08-21-liveramp-integration.mddocs/superpowers/specs/2026-08-21-liveramp-integration-design.mdtrusted-server.example.tomlCloses
Closes #355
Test plan
cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spincargo 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-codegencargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && node build-all.mjscd crates/trusted-server-js/lib && npm run lint && npm run format./scripts/test-cli.shcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveHardening note
This change does not add config-derived regular-expression or pattern compilation. Invalid enabled LiveRamp configuration is rejected by typed validation and covered by Rust and CLI regression tests.
Checklist
unwrap()calls were introduced in production code.Acceptance boundary
RampID identity envelopes traverse the existing Prebid auction path; ATS Direct audience segments are not passed by this implementation.