From 09da4e07f413128652612c51767ac362a3526122 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 5 May 2026 15:34:12 -0500 Subject: [PATCH 1/5] Add EC consent decision logging --- .../src/logging.rs | 25 ++- crates/trusted-server-core/src/consent/mod.rs | 169 +++++++++++++----- crates/trusted-server-core/src/ec/consent.rs | 10 +- crates/trusted-server-core/src/ec/finalize.rs | 63 ++++++- crates/trusted-server-core/src/ec/mod.rs | 77 +++++++- .../src/integrations/registry.rs | 8 +- crates/trusted-server-core/src/publisher.rs | 15 +- 7 files changed, 304 insertions(+), 63 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/logging.rs b/crates/trusted-server-adapter-fastly/src/logging.rs index f110c63b..09d43f62 100644 --- a/crates/trusted-server-adapter-fastly/src/logging.rs +++ b/crates/trusted-server-adapter-fastly/src/logging.rs @@ -3,6 +3,8 @@ use chrono::{SecondsFormat, Utc}; use log_fastly::Logger; +const ENV_TS_LOG_LEVEL: &str = "TS_LOG_LEVEL"; + /// Extracts the final `::` segment from a Rust module path for use as a log label. /// /// When the input has no `::` separator, returns the full target. When the @@ -27,14 +29,16 @@ fn target_label(target: &str) -> &str { /// Panics if the Fastly logger cannot be built or if the global logger has already /// been set. pub(crate) fn init_logger() { + let max_level = configured_log_level(); let logger = Logger::builder() .default_endpoint("tslog") .echo_stdout(true) - .max_level(log::LevelFilter::Info) + .max_level(max_level) .build() .expect("should build Logger"); fern::Dispatch::new() + .level(max_level) .format(|out, message, record| { out.finish(format_args!( "{} {} [{}] {}", @@ -49,6 +53,25 @@ pub(crate) fn init_logger() { .expect("should initialize logger"); } +fn configured_log_level() -> log::LevelFilter { + std::env::var(ENV_TS_LOG_LEVEL) + .ok() + .and_then(|value| parse_log_level(&value)) + .unwrap_or(log::LevelFilter::Info) +} + +fn parse_log_level(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "off" => Some(log::LevelFilter::Off), + "error" => Some(log::LevelFilter::Error), + "warn" | "warning" => Some(log::LevelFilter::Warn), + "info" => Some(log::LevelFilter::Info), + "debug" => Some(log::LevelFilter::Debug), + "trace" => Some(log::LevelFilter::Trace), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 6dff4121..1159cf07 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -103,7 +103,7 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext let gdpr_applies = has_eu_tcf_signal(signals.raw_tc_string.is_some(), gpp_section_ids.as_deref()); log::debug!("Consent proxy mode: jurisdiction={jur}, skipping decode"); - return ConsentContext { + let ctx = ConsentContext { raw_tc_string: signals.raw_tc_string, raw_gpp_string: signals.raw_gpp_string, gpp_section_ids, @@ -118,6 +118,8 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext jurisdiction: jur, source: ConsentSource::Cookie, }; + log_consent_context(&ctx); + return ctx; } let mut ctx = build_context_from_signals(&signals); @@ -456,6 +458,120 @@ pub fn gate_eids_by_consent( // EC consent gating // --------------------------------------------------------------------------- +/// Reason for an Edge Cookie creation consent decision. +#[derive(Debug, Copy, Clone, Eq, PartialEq, derive_more::Display)] +pub enum EcConsentReason { + /// GDPR applies and TCF Purpose 1 storage consent is granted. + GdprStorageConsentGranted, + /// GDPR applies but no decoded TCF signal is available. + GdprMissingTcf, + /// GDPR applies and TCF Purpose 1 storage consent is denied. + GdprStorageConsentDenied, + /// A US state privacy regime applies and GPC opts the user out. + UsGpcOptOut, + /// A US state privacy regime applies and TCF Purpose 1 storage consent is granted. + UsTcfStorageConsentGranted, + /// A US state privacy regime applies and TCF Purpose 1 storage consent is denied. + UsTcfStorageConsentDenied, + /// A US state privacy regime applies and GPP does not opt the user out of sale. + UsGppNoSaleOptOut, + /// A US state privacy regime applies and GPP opts the user out of sale. + UsGppSaleOptOut, + /// A US state privacy regime applies and US Privacy does not opt the user out of sale. + UsPrivacyNoSaleOptOut, + /// A US state privacy regime applies and US Privacy opts the user out of sale. + UsPrivacySaleOptOut, + /// A US state privacy regime applies but no usable consent signal is present. + UsMissingSignals, + /// The request is outside regulated jurisdictions. + NonRegulated, + /// Jurisdiction cannot be determined, so EC creation fails closed. + UnknownJurisdiction, +} + +/// Result of evaluating whether Edge Cookie creation is permitted. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct EcConsentDecision { + /// Whether EC creation is permitted. + pub allowed: bool, + /// The branch responsible for the decision. + pub reason: EcConsentReason, +} + +/// Determines whether Edge Cookie (EC) creation is permitted and why. +#[must_use] +pub fn ec_creation_decision(ctx: &ConsentContext) -> EcConsentDecision { + match &ctx.jurisdiction { + jurisdiction::Jurisdiction::Gdpr => match effective_tcf(ctx) { + Some(tcf) if tcf.has_storage_consent() => EcConsentDecision { + allowed: true, + reason: EcConsentReason::GdprStorageConsentGranted, + }, + Some(_) => EcConsentDecision { + allowed: false, + reason: EcConsentReason::GdprStorageConsentDenied, + }, + None => EcConsentDecision { + allowed: false, + reason: EcConsentReason::GdprMissingTcf, + }, + }, + jurisdiction::Jurisdiction::UsState(_) => { + if ctx.gpc { + return EcConsentDecision { + allowed: false, + reason: EcConsentReason::UsGpcOptOut, + }; + } + if let Some(tcf) = effective_tcf(ctx) { + return EcConsentDecision { + allowed: tcf.has_storage_consent(), + reason: if tcf.has_storage_consent() { + EcConsentReason::UsTcfStorageConsentGranted + } else { + EcConsentReason::UsTcfStorageConsentDenied + }, + }; + } + if let Some(gpp) = &ctx.gpp { + if let Some(opted_out) = gpp.us_sale_opt_out { + return EcConsentDecision { + allowed: !opted_out, + reason: if opted_out { + EcConsentReason::UsGppSaleOptOut + } else { + EcConsentReason::UsGppNoSaleOptOut + }, + }; + } + } + if let Some(usp) = &ctx.us_privacy { + let opted_out = usp.opt_out_sale == PrivacyFlag::Yes; + return EcConsentDecision { + allowed: !opted_out, + reason: if opted_out { + EcConsentReason::UsPrivacySaleOptOut + } else { + EcConsentReason::UsPrivacyNoSaleOptOut + }, + }; + } + EcConsentDecision { + allowed: false, + reason: EcConsentReason::UsMissingSignals, + } + } + jurisdiction::Jurisdiction::NonRegulated => EcConsentDecision { + allowed: true, + reason: EcConsentReason::NonRegulated, + }, + jurisdiction::Jurisdiction::Unknown => EcConsentDecision { + allowed: false, + reason: EcConsentReason::UnknownJurisdiction, + }, + } +} + /// Determines whether Edge Cookie (EC) creation is permitted based on the /// user's consent and detected jurisdiction. /// @@ -473,46 +589,7 @@ pub fn gate_eids_by_consent( /// blocked as a precaution. #[must_use] pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - // EU/UK: explicit opt-in required (TCF Purpose 1 = store/access device). - match effective_tcf(ctx) { - Some(tcf) => tcf.has_storage_consent(), - None => false, - } - } - jurisdiction::Jurisdiction::UsState(_) => { - // GPC is an independent opt-out signal — it always blocks EC - // creation regardless of what the US Privacy string says. - if ctx.gpc { - return false; - } - // When a CMP uses TCF in the US (e.g. Didomi), respect the - // TCF Purpose 1 decision — this is an explicit opt-in signal. - // The Sourcepoint GPP design documents this precedence decision. - if let Some(tcf) = effective_tcf(ctx) { - return tcf.has_storage_consent(); - } - // Check GPP US section for sale opt-out. - if let Some(gpp) = &ctx.gpp { - if let Some(opted_out) = gpp.us_sale_opt_out { - return !opted_out; - } - } - // Check US Privacy string for explicit opt-out. - if let Some(usp) = &ctx.us_privacy { - return usp.opt_out_sale != PrivacyFlag::Yes; - } - // Spec §6.1.1: "In regulated jurisdictions (GDPR, US state), - // consent cookies/headers must be present for - // allows_ec_creation() to return true." No signals = block. - false - } - jurisdiction::Jurisdiction::NonRegulated => true, - // No geolocation data — cannot determine jurisdiction. - // Fail-closed: block EC creation as a precaution. - jurisdiction::Jurisdiction::Unknown => false, - } + ec_creation_decision(ctx).allowed } /// Returns `true` only when the request contains an explicit EC opt-out signal. @@ -575,10 +652,6 @@ fn signal_status(decoded: bool, raw: bool) -> &'static str { /// Logs a structured summary of the fully-processed consent context. fn log_consent_context(ctx: &ConsentContext) { - if ctx.is_empty() { - return; - } - let tcf_status = match (&ctx.tcf, ctx.expired) { (Some(_), _) => "present", (None, true) => "expired", @@ -589,13 +662,17 @@ fn log_consent_context(ctx: &ConsentContext) { let gpp_status = signal_status(ctx.gpp.is_some(), ctx.raw_gpp_string.is_some()); let usp_status = signal_status(ctx.us_privacy.is_some(), ctx.raw_us_privacy.is_some()); + let ec_decision = ec_creation_decision(ctx); log::info!( "Consent context: jurisdiction={}, tcf={tcf_status}, gpp={gpp_status}, \ - us_privacy={usp_status}, gpc={}, gdpr_applies={}, source={:?}", + us_privacy={usp_status}, gpc={}, gdpr_applies={}, source={:?}, \ + ec_allowed={}, ec_reason={}", ctx.jurisdiction, ctx.gpc, ctx.gdpr_applies, ctx.source, + ec_decision.allowed, + ec_decision.reason, ); } diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd2..a59db914 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -6,7 +6,7 @@ //! eventual migration path (renaming, adding EC-specific conditions) is //! contained here. -use crate::consent::ConsentContext; +use crate::consent::{ConsentContext, EcConsentDecision}; /// Determines whether Edge Cookie creation is permitted based on the /// user's consent and detected jurisdiction. @@ -18,7 +18,13 @@ use crate::consent::ConsentContext; /// See [`crate::consent::allows_ec_creation`] for the full decision matrix. #[must_use] pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) + ec_consent_decision(consent_context).allowed +} + +/// Determines whether Edge Cookie creation is permitted and why. +#[must_use] +pub fn ec_consent_decision(consent_context: &ConsentContext) -> EcConsentDecision { + crate::consent::ec_creation_decision(consent_context) } /// Returns `true` when the request carries an explicit EC withdrawal signal. diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index f08120e9..f17003a8 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; use fastly::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; +use super::consent::{ec_consent_decision, ec_consent_withdrawn}; use crate::constants::HEADER_X_TS_EC; use crate::settings::Settings; @@ -47,7 +47,8 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); + let consent_decision = ec_consent_decision(ec_context.consent()); + let consent_allows_ec = consent_decision.allowed; let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); if !consent_allows_ec { @@ -56,6 +57,17 @@ pub fn ec_finalize_response( // revocation and fail-closed cases such as missing geo or undecodable // consent input. clear_ec_headers_on_response(response, Some(registry)); + log::info!( + "EC finalize: action=clear_headers, consent_allowed=false, \ + consent_reason={}, consent_withdrawn={}, cookie_present={}, \ + ec_present={}, ec_generated={}, kv_available={}", + consent_decision.reason, + consent_withdrawn, + ec_context.cookie_was_present(), + ec_context.ec_value().is_some(), + ec_context.ec_generated(), + kv.is_some(), + ); // Only expire the browser cookie and tombstone the identity-graph row // when the request carries an explicit withdrawal signal. @@ -64,6 +76,13 @@ pub fn ec_finalize_response( // Compute once for the authoritative identity-graph tombstones. let ids_to_withdraw = withdrawal_ec_ids(ec_context); + log::info!( + "EC finalize: action=expire_cookie_and_tombstone, tombstone_count={}, \ + consent_reason={}, kv_available={}", + ids_to_withdraw.len(), + consent_decision.reason, + kv.is_some(), + ); // The identity-graph tombstone is the authoritative withdrawal marker // for subsequent EC behavior. @@ -74,6 +93,11 @@ pub fn ec_finalize_response( "Failed to write withdrawal tombstone for EC ID '{}': {err:?}", log_id(ec_id), ); + } else { + log::info!( + "EC finalize: action=tombstone_written, ec_id={}", + log_id(ec_id), + ); } }); } @@ -97,6 +121,14 @@ pub fn ec_finalize_response( // Returning users keep the active EC visible for this response, but // ordinary page views no longer refresh the browser cookie or KV TTL. set_ec_header_on_response(ec_context, response); + log::info!( + "EC finalize: action=set_returning_header, consent_allowed=true, \ + consent_reason={}, cookie_present={}, ec_present=true, ec_generated=false, \ + kv_available={}", + consent_decision.reason, + ec_context.cookie_was_present(), + kv.is_some(), + ); return; } @@ -106,7 +138,14 @@ pub fn ec_finalize_response( // identity-graph row, producing a phantom ID on later requests. if ec_context.ec_generated() { let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) else { - log::debug!("Skipping generated EC response write because KV graph is unavailable"); + log::info!( + "EC finalize: action=skip_generated_no_kv, consent_allowed=true, \ + consent_reason={}, cookie_present={}, ec_present={}, ec_generated=true, \ + kv_available=false", + consent_decision.reason, + ec_context.cookie_was_present(), + ec_context.ec_value().is_some(), + ); return; }; @@ -117,7 +156,25 @@ pub fn ec_finalize_response( ingest_sharedid_cookie(cookie, ec_id, graph, registry); } set_ec_cookie_and_header_on_response(settings, ec_context, response); + log::info!( + "EC finalize: action=set_generated_cookie_and_header, consent_allowed=true, \ + consent_reason={}, cookie_present={}, ec_present=true, ec_generated=true, \ + kv_available=true, ec_id={}", + consent_decision.reason, + ec_context.cookie_was_present(), + log_id(ec_id), + ); + return; } + + log::info!( + "EC finalize: action=no_ec_output, consent_allowed=true, consent_reason={}, \ + cookie_present={}, ec_present={}, ec_generated=false, kv_available={}", + consent_decision.reason, + ec_context.cookie_was_present(), + ec_context.ec_value().is_some(), + kv.is_some(), + ); } /// Sets the EC response header when an EC ID is available. diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 5e945617..644c0487 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -89,6 +89,21 @@ struct RequestEc { jar: Option, } +#[derive(Debug, Copy, Clone, Eq, PartialEq, derive_more::Display)] +enum EcInputStatus { + Absent, + Valid, + Invalid, +} + +fn ec_input_status(value: Option<&str>) -> EcInputStatus { + match value { + Some(v) if is_valid_ec_id(v) => EcInputStatus::Valid, + Some(_) => EcInputStatus::Invalid, + None => EcInputStatus::Absent, + } +} + /// Parses EC identity from request headers and cookies in a single pass. /// /// # Errors @@ -208,12 +223,30 @@ impl EcContext { // Malformed values are discarded per §4.2: "If the header is // present but malformed, it is discarded and the cookie value // is used instead." + let header_status = ec_input_status(parsed.header_ec.as_deref()); + let cookie_status = ec_input_status(parsed.cookie_ec.as_deref()); + let active_source = if header_status == EcInputStatus::Valid { + "header" + } else if cookie_status == EcInputStatus::Valid { + "cookie" + } else { + "none" + }; + let header_cookie_differ = matches!( + (parsed.header_ec.as_deref(), parsed.cookie_ec.as_deref()), + (Some(header), Some(cookie)) if header != cookie + ); let ec_value = parsed .header_ec .filter(|v| is_valid_ec_id(v)) .or_else(|| parsed.cookie_ec.clone().filter(|v| is_valid_ec_id(v))); let ec_was_present = ec_value.is_some(); + log::info!( + "EC request state: header_ec={header_status}, cookie_ec={cookie_status}, \ + active_source={active_source}, ec_present={ec_was_present}, \ + header_cookie_differ={header_cookie_differ}" + ); if let Some(ref id) = ec_value { log::trace!("Existing EC ID found: {}", log_id(id)); } @@ -259,14 +292,30 @@ impl EcContext { settings: &Settings, kv: Option<&KvIdentityGraph>, ) -> Result<(), Report> { - if self.ec_value.is_some() { + if let Some(ec_id) = self.ec_value.as_deref() { + let decision = consent::ec_consent_decision(&self.consent); + log::info!( + "EC generation decision: action=existing_ec, ec_present=true, \ + ec_generated=false, consent_allowed={}, consent_reason={}, \ + jurisdiction={}, kv_available={}, ec_id={}", + decision.allowed, + decision.reason, + self.consent.jurisdiction, + kv.is_some(), + log_id(ec_id), + ); return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { - log::debug!( - "EC generation skipped: consent not granted (jurisdiction={})", + let decision = consent::ec_consent_decision(&self.consent); + if !decision.allowed { + log::info!( + "EC generation decision: action=blocked_by_consent, ec_present=false, \ + ec_generated=false, consent_allowed=false, consent_reason={}, \ + jurisdiction={}, kv_available={}", + decision.reason, self.consent.jurisdiction, + kv.is_some(), ); return Ok(()); } @@ -302,9 +351,29 @@ impl EcContext { ); self.ec_value = None; self.ec_generated = false; + log::info!( + "EC generation decision: action=generated_but_kv_write_failed, \ + ec_present=false, ec_generated=false, consent_allowed=true, \ + consent_reason={}, jurisdiction={}, kv_available=true", + decision.reason, + self.consent.jurisdiction, + ); + return Ok(()); } } + if let Some(ec_id) = self.ec_value.as_deref() { + log::info!( + "EC generation decision: action=generated, ec_present=false, \ + ec_generated=true, consent_allowed=true, consent_reason={}, \ + jurisdiction={}, kv_available={}, ec_id={}", + decision.reason, + self.consent.jurisdiction, + kv.is_some(), + log_id(ec_id), + ); + } + Ok(()) } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 968696a9..0eba5731 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -667,9 +667,13 @@ impl IntegrationRegistry { log::warn!("EC generation failed for integration proxy: {err:?}"); } } else { - log::debug!( - "EC generation skipped for integration proxy: non-document request (path={})", + log::info!( + "EC generation decision: action=skipped_non_navigation, route=integration_proxy, \ + path={}, ec_present={}, ec_generated={}, consent_allowed={}", path, + ec_context.ec_value().is_some(), + ec_context.ec_generated(), + ec_context.ec_allowed(), ); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index aaba0bcf..8c007f18 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -446,16 +446,21 @@ pub fn handle_publisher_request( log::warn!("EC generation failed: {err:?}"); } } else { - log::debug!( - "EC generation skipped: non-document request (path={})", + log::info!( + "EC generation decision: action=skipped_non_navigation, path={}, \ + ec_present={}, ec_generated={}, consent_allowed={}", req.get_path(), + ec_context.ec_value().is_some(), + ec_context.ec_generated(), + ec_context.ec_allowed(), ); } let ec_allowed = ec_context.ec_allowed(); - log::debug!( - "Proxy EC ID: {:?}, ec_allowed: {ec_allowed}", - ec_context.ec_value(), + log::info!( + "Publisher EC state: ec_present={}, ec_generated={}, ec_allowed={ec_allowed}", + ec_context.ec_value().is_some(), + ec_context.ec_generated(), ); let backend_name = BackendConfig::from_url( From 2266fe9a4747e3346e2822668068c384922db56b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 5 May 2026 15:34:20 -0500 Subject: [PATCH 2/5] Support Sourcepoint consent storage shapes --- .../lib/src/integrations/sourcepoint/index.ts | 204 +++++++++++++++--- .../integrations/sourcepoint/index.test.ts | 33 +++ 2 files changed, 209 insertions(+), 28 deletions(-) diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts index 0e31b086..f732b34c 100644 --- a/crates/js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -5,6 +5,7 @@ const GPP_COOKIE_NAME = '__gpp'; const GPP_SID_COOKIE_NAME = '__gpp_sid'; const GPP_SOURCE_COOKIE_NAME = '_ts_gpp_src'; const GPP_SOURCE_SOURCEPOINT = 'sp'; +const TCF_COOKIE_NAME = 'euconsent-v2'; const INITIAL_RETRY_DELAY_MS = 500; interface SourcepointGppData { @@ -12,15 +13,148 @@ interface SourcepointGppData { applicableSections: number[]; } +interface SourcepointConsentStringEntry { + sectionId?: number; + consentString?: string; +} + +interface SourcepointSectionPayload { + consentString?: string; + applicableSections?: number[]; + consentStrings?: SourcepointConsentStringEntry[]; + tcString?: string; + euconsent?: string; + euconsentV2?: string; +} + interface SourcepointConsentPayload { gppData?: SourcepointGppData; + gdpr?: SourcepointSectionPayload; + usnat?: SourcepointSectionPayload; + [key: string]: unknown; +} + +interface MirroredConsent { + gppString?: string; + gppSections?: number[]; + tcString?: string; } let initialized = false; -function findSourcepointConsent(): SourcepointConsentPayload | null { - // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. - // We intentionally take the first valid match and mirror that origin-scoped payload. +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === 'number'); +} + +function isConsentStringEntryArray(value: unknown): value is SourcepointConsentStringEntry[] { + return ( + Array.isArray(value) && + value.every( + (item) => + isRecord(item) && + (typeof item.sectionId === 'number' || typeof item.sectionId === 'undefined') && + (typeof item.consentString === 'string' || typeof item.consentString === 'undefined') + ) + ); +} + +function normalizeSectionPayload(value: unknown): SourcepointSectionPayload | null { + if (!isRecord(value)) return null; + + return { + consentString: typeof value.consentString === 'string' ? value.consentString : undefined, + applicableSections: isNumberArray(value.applicableSections) + ? value.applicableSections + : undefined, + consentStrings: isConsentStringEntryArray(value.consentStrings) + ? value.consentStrings + : undefined, + tcString: typeof value.tcString === 'string' ? value.tcString : undefined, + euconsent: typeof value.euconsent === 'string' ? value.euconsent : undefined, + euconsentV2: typeof value.euconsentV2 === 'string' ? value.euconsentV2 : undefined, + }; +} + +function sectionIdsFromConsentStrings( + consentStrings: SourcepointConsentStringEntry[] | undefined +): number[] | undefined { + const ids = consentStrings + ?.map((entry) => entry.sectionId) + .filter((sectionId): sectionId is number => typeof sectionId === 'number'); + + return ids && ids.length > 0 ? ids : undefined; +} + +function looksLikeGpp(consentString: string): boolean { + return consentString.includes('~'); +} + +function extractConsentFromSection( + sectionName: string, + section: SourcepointSectionPayload +): MirroredConsent | null { + const gppSections = + section.applicableSections ?? sectionIdsFromConsentStrings(section.consentStrings); + + if (section.consentString && (gppSections || looksLikeGpp(section.consentString))) { + return { + gppString: section.consentString, + gppSections, + }; + } + + if (sectionName === 'gdpr') { + const tcString = + section.tcString ?? section.euconsentV2 ?? section.euconsent ?? section.consentString; + if (tcString) { + return { tcString }; + } + } + + return null; +} + +function mergeConsent( + primary: MirroredConsent | null, + secondary: MirroredConsent | null +): MirroredConsent | null { + if (!primary) return secondary; + if (!secondary) return primary; + + return { + gppString: primary.gppString ?? secondary.gppString, + gppSections: primary.gppSections ?? secondary.gppSections, + tcString: primary.tcString ?? secondary.tcString, + }; +} + +function extractMirroredConsent(payload: SourcepointConsentPayload): MirroredConsent | null { + let consent: MirroredConsent | null = null; + + if (payload.gppData?.gppString) { + consent = mergeConsent(consent, { + gppString: payload.gppData.gppString, + gppSections: payload.gppData.applicableSections, + }); + } + + for (const [sectionName, rawSection] of Object.entries(payload)) { + if (sectionName === 'gppData') continue; + + const section = normalizeSectionPayload(rawSection); + if (!section) continue; + + consent = mergeConsent(consent, extractConsentFromSection(sectionName, section)); + } + + return consent; +} + +function findSourcepointConsent(): MirroredConsent | null { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; @@ -30,8 +164,9 @@ function findSourcepointConsent(): SourcepointConsentPayload | null { try { const payload = JSON.parse(raw) as SourcepointConsentPayload; - if (payload.gppData?.gppString) { - return payload; + const consent = extractMirroredConsent(payload); + if (consent?.gppString || consent?.tcString) { + return consent; } } catch { log.debug('sourcepoint: failed to parse localStorage value', { key }); @@ -87,8 +222,14 @@ function scheduleInitialRetry(): void { } /** - * Reads Sourcepoint consent from localStorage and mirrors it into - * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. + * Reads Sourcepoint consent from localStorage and mirrors it into cookies for + * Trusted Server to read on the next request. + * + * Sourcepoint localStorage differs by campaign/module. US National data is + * commonly stored under `usnat.consentString`/`usnat.applicableSections`, while + * some setups expose `gppData.gppString`. GDPR/UK setups may expose a TCF string + * under `gdpr` fields. Trusted Server cannot read localStorage server-side, so + * this bridge writes the standard IAB cookies. * * Returns `true` if cookies were written, `false` otherwise. */ @@ -97,35 +238,44 @@ export function mirrorSourcepointConsent(): boolean { return false; } - const payload = findSourcepointConsent(); - if (!payload?.gppData) { + const consent = findSourcepointConsent(); + if (!consent) { clearSourcepointCookies(); - log.debug('sourcepoint: no GPP data found in localStorage'); + log.debug('sourcepoint: no consent data found in localStorage'); return false; } - const { gppString, applicableSections } = payload.gppData; - if (!gppString) { + let wroteCookie = false; + + if (consent.gppString) { + writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); + writeCookie(GPP_COOKIE_NAME, consent.gppString); + + if (Array.isArray(consent.gppSections) && consent.gppSections.length > 0) { + writeCookie(GPP_SID_COOKIE_NAME, consent.gppSections.join(',')); + } else { + clearCookie(GPP_SID_COOKIE_NAME); + } + + wroteCookie = true; + } else { clearSourcepointCookies(); - log.debug('sourcepoint: gppString is empty'); - return false; } - writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); - writeCookie(GPP_COOKIE_NAME, gppString); - - if (Array.isArray(applicableSections) && applicableSections.length > 0) { - writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); - } else if (hasSourcepointMarker()) { - clearCookie(GPP_SID_COOKIE_NAME); + if (consent.tcString) { + writeCookie(TCF_COOKIE_NAME, consent.tcString); + wroteCookie = true; } - log.info('sourcepoint: mirrored GPP consent to cookies', { - gppLength: gppString.length, - sections: applicableSections, - }); + if (wroteCookie) { + log.info('sourcepoint: mirrored consent to cookies', { + gppLength: consent.gppString?.length ?? 0, + sections: consent.gppSections, + tcLength: consent.tcString?.length ?? 0, + }); + } - return true; + return wroteCookie; } /** @@ -142,8 +292,6 @@ export function initializeSourcepointConsentMirror(): void { scheduleInitialRetry(); } - // Sourcepoint persists consent changes to localStorage. Re-mirror when a - // user returns to the page so session cookies do not remain stale. document.addEventListener('visibilitychange', mirrorOnVisible); window.addEventListener('focus', mirrorSourcepointConsent); } diff --git a/crates/js/lib/test/integrations/sourcepoint/index.test.ts b/crates/js/lib/test/integrations/sourcepoint/index.test.ts index 3914c2ef..57ef5812 100644 --- a/crates/js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -49,6 +49,39 @@ describe('integrations/sourcepoint', () => { expect(getCookie(SOURCEPOINT_MARKER_COOKIE)).toBe('sp'); }); + it('mirrors __gpp and __gpp_sid from current Sourcepoint usnat localStorage shape', () => { + const payload = { + usnat: { + applicableSections: [7], + consentString: 'DBABLA~BVQqAAAAAgA.QA', + consentStatus: { + consentedToAll: true, + }, + }, + }; + localStorage.setItem('_sp_user_consent_36922', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(getCookie('__gpp')).toBe('DBABLA~BVQqAAAAAgA.QA'); + expect(getCookie('__gpp_sid')).toBe('7'); + }); + + it('mirrors euconsent-v2 from Sourcepoint gdpr localStorage shape', () => { + const payload = { + gdpr: { + consentString: 'CPXxGfAPXxGfAAHABBENBCCsAP_AAH_AAAAAHftf', + }, + }; + localStorage.setItem('_sp_user_consent_36922', JSON.stringify(payload)); + + const result = mirrorSourcepointConsent(); + + expect(result).toBe(true); + expect(getCookie('euconsent-v2')).toBe('CPXxGfAPXxGfAAHABBENBCCsAP_AAH_AAAAAHftf'); + }); + it('handles multiple applicable sections', () => { localStorage.setItem( '_sp_user_consent_99999', From 6f2ea3713c0f21ff547791ce9328f2cfcd58ec42 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 5 May 2026 15:34:38 -0500 Subject: [PATCH 3/5] Add Prebid user ID module support --- crates/js/lib/build-all.mjs | 113 +++++++++++++----- .../prebid/_user_id_modules.generated.ts | 6 + .../js/lib/src/integrations/prebid/index.ts | 19 ++- .../test/integrations/prebid/index.test.ts | 36 +++++- .../src/integrations/prebid.rs | 42 +++++++ trusted-server.toml | 9 ++ 6 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 crates/js/lib/src/integrations/prebid/_user_id_modules.generated.ts diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index cc5690e0..c0086aa8 100644 --- a/crates/js/lib/build-all.mjs +++ b/crates/js/lib/build-all.mjs @@ -13,6 +13,10 @@ * names to include in the bundle (e.g. "rubicon,appnexus,openx"). * Each name must have a corresponding {name}BidAdapter.js module in * the prebid.js package. Default: "rubicon". + * TSJS_PREBID_USER_ID_MODULES — Comma-separated list of Prebid.js User ID + * modules to include in the bundle (e.g. "sharedId,pubProvided,uid2"). + * Names may be module bases ("sharedId") or exact module names + * ("sharedIdSystem"). Default: empty. */ import fs from 'node:fs'; @@ -30,11 +34,10 @@ const integrationsDir = path.join(srcDir, 'integrations'); // --------------------------------------------------------------------------- const DEFAULT_PREBID_ADAPTERS = 'rubicon'; -const ADAPTERS_FILE = path.join( - integrationsDir, - 'prebid', - '_adapters.generated.ts', -); +const DEFAULT_PREBID_USER_ID_MODULES = ''; +const prebidIntegrationDir = path.join(integrationsDir, 'prebid'); +const ADAPTERS_FILE = path.join(prebidIntegrationDir, '_adapters.generated.ts'); +const USER_ID_MODULES_FILE = path.join(prebidIntegrationDir, '_user_id_modules.generated.ts'); /** * Generate `_adapters.generated.ts` with import statements for each adapter @@ -43,36 +46,34 @@ const ADAPTERS_FILE = path.join( * Invalid adapter names (those without a matching module in prebid.js) are * logged and skipped. */ -function generatePrebidAdapters() { - const raw = process.env.TSJS_PREBID_ADAPTERS || DEFAULT_PREBID_ADAPTERS; - const names = raw +const prebidModulesDir = path.join(__dirname, 'node_modules', 'prebid.js', 'modules'); + +function parseCommaSeparatedEnv(name, fallback) { + return (process.env[name] || fallback) .split(',') .map((s) => s.trim()) .filter(Boolean); +} + +function generatePrebidAdapters() { + const names = parseCommaSeparatedEnv('TSJS_PREBID_ADAPTERS', DEFAULT_PREBID_ADAPTERS); if (names.length === 0) { console.warn( '[build-all] TSJS_PREBID_ADAPTERS is empty, falling back to default:', - DEFAULT_PREBID_ADAPTERS, + DEFAULT_PREBID_ADAPTERS ); names.push(DEFAULT_PREBID_ADAPTERS); } - const modulesDir = path.join( - __dirname, - 'node_modules', - 'prebid.js', - 'modules', - ); - // Validate each adapter and build import lines const imports = []; for (const name of names) { const moduleFile = `${name}BidAdapter.js`; - const modulePath = path.join(modulesDir, moduleFile); + const modulePath = path.join(prebidModulesDir, moduleFile); if (!fs.existsSync(modulePath)) { console.error( - `[build-all] WARNING: Prebid adapter "${name}" not found (expected ${moduleFile}), skipping`, + `[build-all] WARNING: Prebid adapter "${name}" not found (expected ${moduleFile}), skipping` ); continue; } @@ -81,7 +82,7 @@ function generatePrebidAdapters() { if (imports.length === 0) { console.error( - '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters', + '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters' ); } @@ -94,18 +95,77 @@ function generatePrebidAdapters() { `// Default: "${DEFAULT_PREBID_ADAPTERS}"`, '', ...imports, - '', ].join('\n'); - fs.writeFileSync(ADAPTERS_FILE, content); + fs.writeFileSync(ADAPTERS_FILE, `${content}\n`); const adapterNames = names.filter((name) => - fs.existsSync(path.join(modulesDir, `${name}BidAdapter.js`)), + fs.existsSync(path.join(prebidModulesDir, `${name}BidAdapter.js`)) ); console.log('[build-all] Prebid adapters:', adapterNames); } +function prebidModuleExists(moduleFile) { + const sourceFile = path.join(prebidModulesDir, moduleFile); + const sourceTsFile = path.join(prebidModulesDir, moduleFile.replace(/\.js$/, '.ts')); + const publicDistFile = path.join( + __dirname, + 'node_modules', + 'prebid.js', + 'dist', + 'src', + 'public', + moduleFile + ); + + return fs.existsSync(sourceFile) || fs.existsSync(sourceTsFile) || fs.existsSync(publicDistFile); +} + +function resolvePrebidUserIdModule(name) { + const candidates = [ + `${name}.js`, + `${name}System.js`, + `${name}IdSystem.js`, + `${name}IdSubmodule.js`, + ]; + + return candidates.find(prebidModuleExists); +} + +function generatePrebidUserIdModules() { + const names = parseCommaSeparatedEnv( + 'TSJS_PREBID_USER_ID_MODULES', + DEFAULT_PREBID_USER_ID_MODULES + ); + + const imports = []; + const moduleNames = []; + for (const name of names) { + const moduleFile = resolvePrebidUserIdModule(name); + if (!moduleFile) { + console.error(`[build-all] WARNING: Prebid User ID module "${name}" not found, skipping`); + continue; + } + imports.push(`import 'prebid.js/modules/${moduleFile}';`); + moduleNames.push(moduleFile.replace(/\.js$/, '')); + } + + const content = [ + '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', + '//', + '// Controls which Prebid.js User ID modules are included in the bundle.', + '// Set the TSJS_PREBID_USER_ID_MODULES environment variable to a comma-separated list', + '// of module names (e.g. "sharedId,pubProvided,uid2") before building.', + '// Default: empty', + ...(imports.length > 0 ? ['', ...imports] : []), + ].join('\n'); + + fs.writeFileSync(USER_ID_MODULES_FILE, `${content}\n`); + console.log('[build-all] Prebid User ID modules:', moduleNames); +} + generatePrebidAdapters(); +generatePrebidUserIdModules(); // --------------------------------------------------------------------------- @@ -120,8 +180,7 @@ const integrationModules = fs.existsSync(integrationsDir) .filter((name) => { const fullPath = path.join(integrationsDir, name); return ( - fs.statSync(fullPath).isDirectory() && - fs.existsSync(path.join(fullPath, 'index.ts')) + fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) ); }) .sort() @@ -143,7 +202,7 @@ async function buildModule(name, entryPath) { // "exports" map, but we need it for client-side bidder validation. 'prebid.js/src/adapterManager.js': path.resolve( __dirname, - 'node_modules/prebid.js/dist/src/src/adapterManager.js', + 'node_modules/prebid.js/dist/src/src/adapterManager.js' ), }, }, @@ -176,9 +235,7 @@ async function buildModule(name, entryPath) { await buildModule('core', path.join(srcDir, 'core', 'index.ts')); await Promise.all( - integrationModules.map((name) => - buildModule(name, path.join(integrationsDir, name, 'index.ts')), - ), + integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) ); // List all built files diff --git a/crates/js/lib/src/integrations/prebid/_user_id_modules.generated.ts b/crates/js/lib/src/integrations/prebid/_user_id_modules.generated.ts new file mode 100644 index 00000000..9aaf1625 --- /dev/null +++ b/crates/js/lib/src/integrations/prebid/_user_id_modules.generated.ts @@ -0,0 +1,6 @@ +// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// +// Controls which Prebid.js User ID modules are included in the bundle. +// Set the TSJS_PREBID_USER_ID_MODULES environment variable to a comma-separated list +// of module names (e.g. "sharedId,pubProvided,uid2") before building. +// Default: empty diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index b8d6d73b..39e98c7c 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -18,6 +18,11 @@ import 'prebid.js/modules/consentManagementGpp.js'; import 'prebid.js/modules/consentManagementUsp.js'; import 'prebid.js/modules/userId.js'; +// User ID submodules — self-register with prebid.js on import. +// The set of submodules is controlled by the TSJS_PREBID_USER_ID_MODULES env var +// at build time. See _user_id_modules.generated.ts (written by build-all.mjs). +import './_user_id_modules.generated'; + // Client-side bid adapters — self-register with prebid.js on import. // The set of adapters is controlled by the TSJS_PREBID_ADAPTERS env var at // build time. See _adapters.generated.ts (written by build-all.mjs). @@ -42,6 +47,8 @@ export interface PrebidNpmConfig { timeout?: number; /** Enable Prebid.js debug logging. Defaults to false. */ debug?: boolean; + /** Prebid.js userSync configuration for User ID modules. */ + userSync?: unknown; } /** @@ -53,6 +60,8 @@ interface InjectedPrebidConfig { timeout?: number; debug?: boolean; bidders?: string[]; + /** Prebid.js userSync configuration for User ID modules. */ + userSync?: unknown; /** Bidders that run client-side via native Prebid.js adapters. */ clientSideBidders?: string[]; } @@ -177,6 +186,10 @@ function isDefined(value: T | undefined): value is T { return value !== undefined; } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -218,6 +231,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs endpoint: config?.endpoint, timeout: config?.timeout ?? injected?.timeout, debug: config?.debug ?? injected?.debug, + userSync: config?.userSync ?? injected?.userSync, }; auctionEndpoint = merged.endpoint ?? '/auction'; @@ -366,12 +380,15 @@ export function installPrebidNpm(config?: Partial): typeof pbjs }; // Apply initial configuration - const pbjsConfig: PbjsConfig & { bidderTimeout?: number } = { + const pbjsConfig: PbjsConfig & { bidderTimeout?: number; userSync?: unknown } = { debug: merged.debug ?? false, }; if (typeof merged.timeout === 'number') { pbjsConfig.bidderTimeout = merged.timeout; } + if (isRecord(merged.userSync)) { + pbjsConfig.userSync = merged.userSync; + } pbjs.setConfig(pbjsConfig as PbjsConfig); // processQueue() must be called after all modules are loaded when using diff --git a/crates/js/lib/test/integrations/prebid/index.test.ts b/crates/js/lib/test/integrations/prebid/index.test.ts index 47034861..5e8124ec 100644 --- a/crates/js/lib/test/integrations/prebid/index.test.ts +++ b/crates/js/lib/test/integrations/prebid/index.test.ts @@ -53,7 +53,8 @@ vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); vi.mock('prebid.js/modules/userId.js', () => ({})); -// Mock the build-generated adapter imports (no-op in tests) +// Mock the build-generated module imports (no-op in tests) +vi.mock('../../../src/integrations/prebid/_user_id_modules.generated', () => ({})); vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); import { @@ -233,14 +234,33 @@ describe('prebid/installPrebidNpm', () => { }); it('respects custom config values', () => { + const userSync = { + userIds: [ + { + name: 'sharedId', + storage: { name: '_sharedID', type: 'cookie', expires: 365 }, + }, + ], + auctionDelay: 100, + }; + installPrebidNpm({ endpoint: '/custom/auction', timeout: 2000, debug: true, + userSync, }); expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: true, bidderTimeout: 2000 }) + expect.objectContaining({ debug: true, bidderTimeout: 2000, userSync }) + ); + }); + + it('ignores non-object userSync config values', () => { + installPrebidNpm({ userSync: 'sharedId' }); + + expect(mockSetConfig).toHaveBeenCalledWith( + expect.not.objectContaining({ userSync: expect.anything() }) ); }); @@ -756,6 +776,18 @@ describe('prebid/installPrebidNpm with server-injected config', () => { ); }); + it('reads userSync from window.__tsjs_prebid', () => { + const userSync = { + userIds: [{ name: 'sharedId', storage: { name: '_sharedID', type: 'cookie' } }], + auctionDelay: 50, + }; + (window as any).__tsjs_prebid = { userSync }; + + installPrebidNpm(); + + expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining({ userSync })); + }); + it('works with no config argument and no injected config', () => { installPrebidNpm(); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d38c43a4..5dd8f535 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -98,6 +98,14 @@ pub struct PrebidIntegrationConfig { /// manages both lists explicitly. #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] pub client_side_bidders: Vec, + /// Prebid.js `userSync` client configuration for User ID modules. + /// + /// The configured object is injected into `window.__tsjs_prebid.userSync` + /// and passed through to `pbjs.setConfig({ userSync })`. The corresponding + /// Prebid.js User ID modules must be included in the JS bundle at build + /// time with `TSJS_PREBID_USER_ID_MODULES`. + #[serde(default)] + pub user_sync: Option, /// Per-bidder, per-zone param overrides. The outer key is a bidder name, the /// inner key is a zone name (sent by the JS adapter from `mediaTypes.banner.name` /// — a non-standard Prebid.js field used as a temporary workaround), @@ -364,6 +372,8 @@ impl IntegrationHeadInjector for PrebidIntegration { timeout: u32, debug: bool, bidders: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + user_sync: Option<&'a Json>, #[serde(skip_serializing_if = "<[String]>::is_empty")] client_side_bidders: &'a [String], } @@ -373,6 +383,7 @@ impl IntegrationHeadInjector for PrebidIntegration { timeout: self.config.timeout_ms, debug: self.config.debug, bidders: &self.config.bidders, + user_sync: self.config.user_sync.as_ref(), client_side_bidders: &self.config.client_side_bidders, }; @@ -1348,6 +1359,7 @@ mod tests { debug_query_params: None, script_patterns: default_script_patterns(), client_side_bidders: Vec::new(), + user_sync: None, bid_param_zone_overrides: HashMap::new(), consent_forwarding: ConsentForwardingMode::Both, } @@ -1843,6 +1855,36 @@ server_url = "https://prebid.example" ); } + #[test] + fn head_injector_includes_user_sync_when_configured() { + let mut config = base_config(); + config.user_sync = Some(json!({ + "userIds": [ + { + "name": "sharedId", + "storage": {"name": "_sharedID", "type": "cookie", "expires": 365} + } + ], + "auctionDelay": 100 + })); + 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#""userSync":{"auctionDelay":100,"userIds":[{"name":"sharedId""#), + "should include userSync object: {}", + script + ); + } + #[test] fn to_openrtb_includes_debug_flags_when_enabled() { let mut config = base_config(); diff --git a/trusted-server.toml b/trusted-server.toml index 1b51f872..4c458682 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -84,6 +84,15 @@ debug = false # be statically imported in the JS bundle. client_side_bidders = ["rubicon"] +# Optional Prebid.js User ID configuration. The matching User ID modules must +# be included at JS build time with TSJS_PREBID_USER_ID_MODULES, for example: +# TSJS_PREBID_USER_ID_MODULES=sharedId,pubProvided,uid2 node build-all.mjs +# [integrations.prebid.user_sync] +# auctionDelay = 100 +# [[integrations.prebid.user_sync.userIds]] +# name = "sharedId" +# storage = {name = "_sharedID", type = "cookie", expires = 365} + # Zone-specific bid param overrides for Kargo s2s placement IDs. # The JS adapter reads the zone from mediaTypes.banner.name on each ad unit # and includes it in the request. The server maps zone → s2s placementId here. From 1005bf2727a02fbb43ac00e737fba3e6d9c1dc20 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 5 May 2026 15:34:41 -0500 Subject: [PATCH 4/5] Preserve Prebid user ID module imports in builds --- crates/js/lib/build-all.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index c0086aa8..87ff479f 100644 --- a/crates/js/lib/build-all.mjs +++ b/crates/js/lib/build-all.mjs @@ -204,6 +204,14 @@ async function buildModule(name, entryPath) { __dirname, 'node_modules/prebid.js/dist/src/src/adapterManager.js' ), + // The published liveIntentIdSystem module contains a build-time + // require() switch that is not replaced by our Vite build. Import the + // standard implementation directly so browser bundles do not contain + // CommonJS require calls. + 'prebid.js/modules/liveIntentIdSystem.js': path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/libraries/liveIntentId/idSystem.js' + ), }, }, build: { From 97fb85a5744998a1500ccbf067c0217b706053fc Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 5 May 2026 15:42:58 -0500 Subject: [PATCH 5/5] Use valid EC passphrase in CI --- .github/actions/setup-integration-test-env/action.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index a491eb6e..a1b256f6 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -80,7 +80,7 @@ runs: env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret + TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-32-bytes TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1dd8f032..3ea74e4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080 TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret + TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-32-bytes TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1