diff --git a/crates/common/src/auction/context.rs b/crates/common/src/auction/context.rs new file mode 100644 index 00000000..9298a89c --- /dev/null +++ b/crates/common/src/auction/context.rs @@ -0,0 +1,248 @@ +//! Context query-parameter forwarding for auction providers. +//! +//! Provides a config-driven mechanism for ad-server / mediator providers to +//! forward integration-supplied data (e.g. audience segments) as URL query +//! parameters without hard-coding integration-specific knowledge. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; + +/// A strongly-typed context value forwarded from the JS client payload. +/// +/// Replaces raw `serde_json::Value` so that consumers get compile-time +/// exhaustiveness checks. The `#[serde(untagged)]` attribute preserves +/// wire-format compatibility — the JS client sends plain JSON arrays, strings, +/// or numbers which serde maps to the matching variant in declaration order. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum ContextValue { + /// A list of string values (e.g. audience segment IDs). + StringList(Vec), + /// A single string value. + Text(String), + /// A numeric value. + Number(f64), +} + +/// Mapping from auction-request context keys to query-parameter names. +/// +/// Used by ad-server / mediator providers to forward integration-supplied data +/// (e.g. audience segments) as URL query parameters without hard-coding +/// integration-specific knowledge. +/// +/// ```toml +/// [integrations.adserver_mock.context_query_params] +/// permutive_segments = "permutive" +/// lockr_ids = "lockr" +/// ``` +pub type ContextQueryParams = BTreeMap; + +/// Build a URL by appending context values as query parameters according to the +/// provided mapping. +/// +/// For each entry in `mapping`, if the corresponding key exists in `context`: +/// - **Arrays** are serialised as a comma-separated string. +/// - **Strings / numbers** are serialised as-is. +/// - Other JSON types are skipped. +/// +/// The [`url::Url`] crate is used for construction so all values are +/// percent-encoded, preventing query-parameter injection. +/// +/// Returns the original `base_url` unchanged when no parameters are appended. +#[must_use] +pub fn build_url_with_context_params( + base_url: &str, + context: &HashMap, + mapping: &ContextQueryParams, +) -> String { + let Ok(mut url) = url::Url::parse(base_url) else { + log::warn!("build_url_with_context_params: failed to parse base URL, returning as-is"); + return base_url.to_string(); + }; + + let mut appended = 0usize; + + for (context_key, param_name) in mapping { + if let Some(value) = context.get(context_key) { + let serialized = serialize_context_value(value); + if !serialized.is_empty() { + url.query_pairs_mut().append_pair(param_name, &serialized); + appended += 1; + } + } + } + + if appended > 0 { + log::info!( + "build_url_with_context_params: appended {} context query params", + appended + ); + } + + url.to_string() +} + +/// Serialise a single [`ContextValue`] into a string suitable for a query +/// parameter value. String lists are joined with commas; strings and numbers +/// are returned directly. +fn serialize_context_value(value: &ContextValue) -> String { + match value { + ContextValue::StringList(items) => items.join(","), + ContextValue::Text(s) => s.clone(), + ContextValue::Number(n) => n.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_url_with_context_params_appends_array() { + let context = HashMap::from([( + "permutive_segments".to_string(), + ContextValue::StringList(vec!["10000001".into(), "10000003".into(), "adv".into()]), + )]); + let mapping = BTreeMap::from([("permutive_segments".to_string(), "permutive".to_string())]); + + let url = build_url_with_context_params( + "http://localhost:6767/adserver/mediate", + &context, + &mapping, + ); + assert_eq!( + url, + "http://localhost:6767/adserver/mediate?permutive=10000001%2C10000003%2Cadv" + ); + } + + #[test] + fn test_build_url_with_context_params_preserves_existing_query() { + let context = HashMap::from([( + "permutive_segments".to_string(), + ContextValue::StringList(vec!["123".into(), "adv".into()]), + )]); + let mapping = BTreeMap::from([("permutive_segments".to_string(), "permutive".to_string())]); + + let url = build_url_with_context_params( + "http://localhost:6767/adserver/mediate?debug=true", + &context, + &mapping, + ); + assert_eq!( + url, + "http://localhost:6767/adserver/mediate?debug=true&permutive=123%2Cadv" + ); + } + + #[test] + fn test_build_url_with_context_params_no_matching_keys() { + let context = HashMap::new(); + let mapping = BTreeMap::from([("permutive_segments".to_string(), "permutive".to_string())]); + + let url = build_url_with_context_params( + "http://localhost:6767/adserver/mediate", + &context, + &mapping, + ); + assert_eq!(url, "http://localhost:6767/adserver/mediate"); + } + + #[test] + fn test_build_url_with_context_params_empty_array_skipped() { + let context = HashMap::from([( + "permutive_segments".to_string(), + ContextValue::StringList(vec![]), + )]); + let mapping = BTreeMap::from([("permutive_segments".to_string(), "permutive".to_string())]); + + let url = build_url_with_context_params( + "http://localhost:6767/adserver/mediate", + &context, + &mapping, + ); + assert!(!url.contains("permutive=")); + } + + #[test] + fn test_build_url_with_context_params_multiple_mappings() { + let context = HashMap::from([ + ( + "permutive_segments".to_string(), + ContextValue::StringList(vec!["seg1".into()]), + ), + ( + "lockr_ids".to_string(), + ContextValue::Text("lockr-abc-123".into()), + ), + ]); + let mapping = BTreeMap::from([ + ("lockr_ids".to_string(), "lockr".to_string()), + ("permutive_segments".to_string(), "permutive".to_string()), + ]); + + let url = build_url_with_context_params( + "http://localhost:6767/adserver/mediate", + &context, + &mapping, + ); + assert!(url.contains("permutive=seg1")); + assert!(url.contains("lockr=lockr-abc-123")); + } + + #[test] + fn test_build_url_with_context_params_scalar_number() { + let context = HashMap::from([("count".to_string(), ContextValue::Number(42.0))]); + let mapping = BTreeMap::from([("count".to_string(), "n".to_string())]); + + let url = build_url_with_context_params( + "http://localhost:6767/adserver/mediate", + &context, + &mapping, + ); + assert_eq!(url, "http://localhost:6767/adserver/mediate?n=42"); + } + + #[test] + fn test_serialize_context_value_string_list() { + assert_eq!( + serialize_context_value(&ContextValue::StringList(vec![ + "a".into(), + "b".into(), + "3".into() + ])), + "a,b,3" + ); + } + + #[test] + fn test_serialize_context_value_text() { + assert_eq!( + serialize_context_value(&ContextValue::Text("hello".into())), + "hello" + ); + } + + #[test] + fn test_serialize_context_value_number() { + assert_eq!(serialize_context_value(&ContextValue::Number(99.0)), "99"); + } + + #[test] + fn test_context_value_deserialize_array() { + let v: ContextValue = serde_json::from_str(r#"["a","b"]"#).unwrap(); + assert_eq!(v, ContextValue::StringList(vec!["a".into(), "b".into()])); + } + + #[test] + fn test_context_value_deserialize_string() { + let v: ContextValue = serde_json::from_str(r#""hello""#).unwrap(); + assert_eq!(v, ContextValue::Text("hello".into())); + } + + #[test] + fn test_context_value_deserialize_number() { + let v: ContextValue = serde_json::from_str("42").unwrap(); + assert_eq!(v, ContextValue::Number(42.0)); + } +} diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs index 6b446f05..1a3e8ab9 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -9,9 +9,10 @@ use fastly::http::{header, StatusCode}; use fastly::{Request, Response}; use serde::Deserialize; use serde_json::Value as JsonValue; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; +use crate::auction::context::ContextValue; use crate::auction::types::OrchestratorExt; use crate::creative; use crate::error::TrustedServerError; @@ -30,7 +31,6 @@ use super::types::{ #[serde(rename_all = "camelCase")] pub struct AdRequest { pub ad_units: Vec, - #[allow(dead_code)] pub config: Option, } @@ -135,6 +135,46 @@ pub fn convert_tsjs_to_auction_request( geo: Some(geo), }); + // Forward allowed config entries from the JS request into the context map. + // Only keys listed in `auction.allowed_context_keys` are accepted; + // unrecognised keys are silently dropped to prevent injection of + // arbitrary data by a malicious client payload. + let allowed: HashSet<&str> = settings + .auction + .allowed_context_keys + .iter() + .map(String::as_str) + .collect(); + let mut context = HashMap::new(); + if let Some(ref config) = body.config { + if let Some(obj) = config.as_object() { + for (key, value) in obj { + if allowed.contains(key.as_str()) { + match serde_json::from_value::(value.clone()) { + Ok(cv) => { + context.insert(key.clone(), cv); + } + Err(_) => { + log::debug!( + "Auction context: dropping key '{}' with unsupported type", + key + ); + } + } + } else { + log::debug!("Auction context: dropping disallowed key '{}'", key); + } + } + if !context.is_empty() { + log::debug!( + "Auction request context: {} entries ({})", + context.len(), + context.keys().cloned().collect::>().join(", ") + ); + } + } + } + Ok(AuctionRequest { id: Uuid::new_v4().to_string(), slots, @@ -152,7 +192,7 @@ pub fn convert_tsjs_to_auction_request( domain: settings.publisher.domain.clone(), page: format!("https://{}", settings.publisher.domain), }), - context: HashMap::new(), + context, }) } diff --git a/crates/common/src/auction/mod.rs b/crates/common/src/auction/mod.rs index e78f2ecf..92e4d56c 100644 --- a/crates/common/src/auction/mod.rs +++ b/crates/common/src/auction/mod.rs @@ -11,6 +11,7 @@ use crate::settings::Settings; use std::sync::Arc; pub mod config; +pub mod context; pub mod endpoints; pub mod formats; pub mod orchestrator; @@ -18,6 +19,7 @@ pub mod provider; pub mod types; pub use config::AuctionConfig; +pub use context::{build_url_with_context_params, ContextQueryParams, ContextValue}; pub use orchestrator::AuctionOrchestrator; pub use provider::AuctionProvider; pub use types::{ diff --git a/crates/common/src/auction/orchestrator.rs b/crates/common/src/auction/orchestrator.rs index 54aac10d..5568247d 100644 --- a/crates/common/src/auction/orchestrator.rs +++ b/crates/common/src/auction/orchestrator.rs @@ -645,6 +645,7 @@ mod tests { mediator: None, timeout_ms: 2000, creative_store: "creative_store".to_string(), + allowed_context_keys: vec!["permutive_segments".to_string()], }; let orchestrator = AuctionOrchestrator::new(config); diff --git a/crates/common/src/auction/types.rs b/crates/common/src/auction/types.rs index 6c6c4d63..83133d74 100644 --- a/crates/common/src/auction/types.rs +++ b/crates/common/src/auction/types.rs @@ -4,6 +4,7 @@ use fastly::Request; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::auction::context::ContextValue; use crate::geo::GeoInfo; use crate::settings::Settings; @@ -22,8 +23,8 @@ pub struct AuctionRequest { pub device: Option, /// Site information pub site: Option, - /// Additional context - pub context: HashMap, + /// Additional context forwarded from the JS client payload. + pub context: HashMap, } /// Represents a single ad slot/impression. diff --git a/crates/common/src/auction_config_types.rs b/crates/common/src/auction_config_types.rs index 916e839f..1a3ee3a3 100644 --- a/crates/common/src/auction_config_types.rs +++ b/crates/common/src/auction_config_types.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// Auction orchestration configuration. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuctionConfig { /// Enable the auction orchestrator #[serde(default)] @@ -26,6 +26,26 @@ pub struct AuctionConfig { /// KV store name for creative storage (deprecated: creatives are now delivered inline) #[serde(default = "default_creative_store")] pub creative_store: String, + + /// Keys allowed in the auction request context map. + /// Only config entries from the JS payload whose key appears in this list + /// are forwarded into the `AuctionRequest.context`. Unrecognised keys are + /// silently dropped. An empty list blocks all context keys. + #[serde(default = "default_allowed_context_keys")] + pub allowed_context_keys: Vec, +} + +impl Default for AuctionConfig { + fn default() -> Self { + Self { + enabled: false, + providers: Vec::new(), + mediator: None, + timeout_ms: default_timeout(), + creative_store: default_creative_store(), + allowed_context_keys: default_allowed_context_keys(), + } + } } fn default_timeout() -> u32 { @@ -36,6 +56,10 @@ fn default_creative_store() -> String { "creative_store".to_string() } +fn default_allowed_context_keys() -> Vec { + vec![] +} + #[allow(dead_code)] // Methods used in runtime but not in build script impl AuctionConfig { /// Get all provider names. diff --git a/crates/common/src/integrations/adserver_mock.rs b/crates/common/src/integrations/adserver_mock.rs index b84625e5..93ddb374 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -9,10 +9,11 @@ use fastly::http::Method; use fastly::Request; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use validator::Validate; +use crate::auction::context::{build_url_with_context_params, ContextQueryParams}; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, @@ -42,6 +43,17 @@ pub struct AdServerMockConfig { /// Optional price floor (minimum acceptable CPM) #[serde(skip_serializing_if = "Option::is_none")] pub price_floor: Option, + + /// Mapping from auction-request context keys to query-parameter names. + /// Allows forwarding integration-supplied data (e.g. audience segments) + /// to the mediation endpoint without hard-coding integration knowledge. + /// + /// ```toml + /// [integrations.adserver_mock.context_query_params] + /// permutive_segments = "permutive" + /// ``` + #[serde(default)] + pub context_query_params: ContextQueryParams, } fn default_enabled() -> bool { @@ -59,6 +71,7 @@ impl Default for AdServerMockConfig { endpoint: "http://localhost:6767/adserver/mediate".to_string(), timeout_ms: default_timeout_ms(), price_floor: None, + context_query_params: BTreeMap::new(), } } } @@ -85,6 +98,20 @@ impl AdServerMockProvider { Self { config } } + /// Build the mediation endpoint URL, appending context values as query + /// parameters according to the `context_query_params` config mapping. + /// + /// For example, with `context_query_params = { permutive_segments = "permutive" }` + /// and segments `[10000001, 10000003]` in context, the URL becomes + /// `https://…/adserver/mediate?permutive=10000001,10000003`. + fn build_endpoint_url(&self, request: &AuctionRequest) -> String { + build_url_with_context_params( + &self.config.endpoint, + &request.context, + &self.config.context_query_params, + ) + } + /// Build mediation request from auction request and bidder responses. /// /// Handles both: @@ -256,8 +283,11 @@ impl AuctionProvider for AdServerMockProvider { log::debug!("AdServer Mock: mediation request: {:?}", mediation_req); + // Build endpoint URL with optional Permutive segments query string + let endpoint_url = self.build_endpoint_url(request); + // Create HTTP POST request - let mut req = Request::new(Method::POST, &self.config.endpoint); + let mut req = Request::new(Method::POST, &endpoint_url); // Set Host header with port to ensure mocktioneer generates correct iframe URLs if let Ok(url) = url::Url::parse(&self.config.endpoint) { @@ -379,6 +409,7 @@ pub fn register_providers(settings: &Settings) -> Vec> #[cfg(test)] mod tests { use super::*; + use crate::auction::context::ContextValue; use crate::auction::types::*; fn create_test_auction_request() -> AuctionRequest { @@ -421,12 +452,12 @@ mod tests { endpoint: "http://localhost:6767/adserver/mediate".to_string(), timeout_ms: 500, price_floor: Some(1.00), + context_query_params: BTreeMap::new(), }; let provider = AdServerMockProvider::new(config); - let mut auction_request = create_test_auction_request(); + let auction_request = create_test_auction_request(); - // Add bidder responses to context let bidder_responses = vec![ AuctionResponse { provider: "amazon-aps".to_string(), @@ -468,11 +499,6 @@ mod tests { }, ]; - auction_request.context.insert( - "provider_responses".to_string(), - serde_json::to_value(&bidder_responses).expect("should serialize bidder responses"), - ); - let mediation_req = provider .build_mediation_request(&auction_request, &bidder_responses) .expect("should build mediation request"); @@ -713,4 +739,110 @@ mod tests { "Bid without price field should have None price" ); } + + #[test] + fn test_build_endpoint_url_with_context_query_params() { + let config = AdServerMockConfig { + enabled: true, + endpoint: "http://localhost:6767/adserver/mediate".to_string(), + timeout_ms: 500, + price_floor: None, + context_query_params: BTreeMap::from([( + "permutive_segments".to_string(), + "permutive".to_string(), + )]), + }; + let provider = AdServerMockProvider::new(config); + + let mut request = create_test_auction_request(); + request.context.insert( + "permutive_segments".to_string(), + ContextValue::StringList(vec![ + "10000001".into(), + "10000003".into(), + "adv".into(), + "bhgp".into(), + ]), + ); + + let url = provider.build_endpoint_url(&request); + assert_eq!( + url, + "http://localhost:6767/adserver/mediate?permutive=10000001%2C10000003%2Cadv%2Cbhgp" + ); + } + + #[test] + fn test_build_endpoint_url_no_mapping_no_params() { + // With an empty context_query_params, no query params are appended + // even if context contains data. + let config = AdServerMockConfig { + enabled: true, + endpoint: "http://localhost:6767/adserver/mediate".to_string(), + timeout_ms: 500, + price_floor: None, + context_query_params: BTreeMap::new(), + }; + let provider = AdServerMockProvider::new(config); + + let mut request = create_test_auction_request(); + request.context.insert( + "permutive_segments".to_string(), + ContextValue::StringList(vec!["10000001".into()]), + ); + + let url = provider.build_endpoint_url(&request); + assert_eq!(url, "http://localhost:6767/adserver/mediate"); + } + + #[test] + fn test_build_endpoint_url_empty_array_skipped() { + let config = AdServerMockConfig { + context_query_params: BTreeMap::from([( + "permutive_segments".to_string(), + "permutive".to_string(), + )]), + ..Default::default() + }; + let provider = AdServerMockProvider::new(config); + + let mut request = create_test_auction_request(); + request.context.insert( + "permutive_segments".to_string(), + ContextValue::StringList(vec![]), + ); + + let url = provider.build_endpoint_url(&request); + assert!( + !url.contains("permutive="), + "Empty segments should not add query param" + ); + } + + #[test] + fn test_build_endpoint_url_preserves_existing_query_params() { + let config = AdServerMockConfig { + enabled: true, + endpoint: "http://localhost:6767/adserver/mediate?debug=true".to_string(), + timeout_ms: 500, + price_floor: None, + context_query_params: BTreeMap::from([( + "permutive_segments".to_string(), + "permutive".to_string(), + )]), + }; + let provider = AdServerMockProvider::new(config); + + let mut request = create_test_auction_request(); + request.context.insert( + "permutive_segments".to_string(), + ContextValue::StringList(vec!["123".into(), "adv".into()]), + ); + + let url = provider.build_endpoint_url(&request); + assert_eq!( + url, + "http://localhost:6767/adserver/mediate?debug=true&permutive=123%2Cadv" + ); + } } diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index a8510db0..463025ea 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -987,4 +987,45 @@ mod tests { assert!(!rewrite.is_excluded("not a url")); assert!(!rewrite.is_excluded("")); } + + #[test] + fn test_auction_allowed_context_keys_defaults_to_empty() { + let settings = create_test_settings(); + assert!( + settings.auction.allowed_context_keys.is_empty(), + "Default allowed_context_keys should be empty (secure-by-default)" + ); + } + + #[test] + fn test_auction_allowed_context_keys_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + allowed_context_keys = ["permutive_segments", "lockr_ids"] + "#; + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + assert_eq!( + settings.auction.allowed_context_keys, + vec!["permutive_segments", "lockr_ids"] + ); + } + + #[test] + fn test_auction_empty_allowed_context_keys_blocks_all() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + allowed_context_keys = [] + "#; + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + assert!( + settings.auction.allowed_context_keys.is_empty(), + "Empty allowed_context_keys should be respected (blocks all keys)" + ); + } } diff --git a/crates/js/lib/src/core/context.ts b/crates/js/lib/src/core/context.ts new file mode 100644 index 00000000..9ee4ddff --- /dev/null +++ b/crates/js/lib/src/core/context.ts @@ -0,0 +1,44 @@ +// Context provider registry: lets integrations contribute data to auction requests +// without core needing integration-specific knowledge. +import { log } from './log'; + +/** + * A context provider returns key-value pairs to merge into the auction + * request's `config` payload, or `undefined` to contribute nothing. + */ +export type ContextProvider = () => Record | undefined; + +const providers = new Map(); + +/** + * Register a context provider that will be called before every auction request. + * Integrations call this at import time to inject their data (e.g. segments, + * identifiers) into the auction payload without core needing to know about them. + * + * Re-registering with the same `id` replaces the previous provider, preventing + * duplicate accumulation in SPA environments. + */ +export function registerContextProvider(id: string, provider: ContextProvider): void { + providers.set(id, provider); + log.debug('context: registered provider', { id, total: providers.size }); +} + +/** + * Collect context from all registered providers. Called by core's `requestAds` + * to build the `config` object sent to `/auction`. + * + * Each provider's returned keys are merged (later providers win on collision). + * Providers that throw or return `undefined` are silently skipped. + */ +export function collectContext(): Record { + const context: Record = {}; + for (const provider of providers.values()) { + try { + const data = provider(); + if (data) Object.assign(context, data); + } catch { + log.debug('context: provider threw, skipping'); + } + } + return context; +} diff --git a/crates/js/lib/src/core/request.ts b/crates/js/lib/src/core/request.ts index a84af082..1d5e4e08 100644 --- a/crates/js/lib/src/core/request.ts +++ b/crates/js/lib/src/core/request.ts @@ -1,11 +1,10 @@ // Request orchestration for tsjs: unified auction endpoint with iframe-based creative rendering. import { log } from './log'; +import { collectContext } from './context'; import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument } from './render'; import type { RequestAdsCallback, RequestAdsOptions } from './types'; -// getHighestCpmBids is provided by the Prebid extension (shim) to mirror Prebid's API - // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, @@ -24,8 +23,9 @@ export function requestAds( log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); try { const adUnits = getAllUnits(); - const payload = { adUnits, config: {} }; - log.debug('requestAds: payload', { units: adUnits.length }); + const config = collectContext(); + const payload = { adUnits, config }; + log.debug('requestAds: payload', { units: adUnits.length, contextKeys: Object.keys(config) }); // Use unified auction endpoint void requestAdsUnified(payload); diff --git a/crates/js/lib/src/integrations/permutive/index.ts b/crates/js/lib/src/integrations/permutive/index.ts index 84ce148a..60eb5134 100644 --- a/crates/js/lib/src/integrations/permutive/index.ts +++ b/crates/js/lib/src/integrations/permutive/index.ts @@ -1,6 +1,8 @@ import { log } from '../../core/log'; +import { registerContextProvider } from '../../core/context'; import { installPermutiveGuard } from './script_guard'; +import { getPermutiveSegments } from './segments'; declare const permutive: { config: { @@ -100,5 +102,13 @@ function waitForPermutiveSDK(callback: () => void, maxAttempts = 50) { if (typeof window !== 'undefined') { installPermutiveGuard(); + // Register a context provider so Permutive segments are included in auction + // requests. Core calls collectContext() before every /auction POST — this + // keeps all Permutive localStorage knowledge inside this integration. + registerContextProvider('permutive', () => { + const segments = getPermutiveSegments(); + return segments.length > 0 ? { permutive_segments: segments } : undefined; + }); + waitForPermutiveSDK(() => installPermutiveShim()); } diff --git a/crates/js/lib/src/integrations/permutive/segments.ts b/crates/js/lib/src/integrations/permutive/segments.ts new file mode 100644 index 00000000..0ac965b5 --- /dev/null +++ b/crates/js/lib/src/integrations/permutive/segments.ts @@ -0,0 +1,59 @@ +// Permutive segment extraction from localStorage. +// This logic is owned by the Permutive integration, keeping core free of +// integration-specific data-reading code. +import { log } from '../../core/log'; + +/** Upper bound on the number of segments we forward to avoid oversized URLs. */ +const MAX_SEGMENTS = 100; + +/** + * Read Permutive segment IDs from localStorage. + * + * Permutive stores cohort data in the `permutive-app` key. We check two + * locations (most reliable first): + * + * 1. `core.cohorts.all` — full cohort membership (numeric IDs + activation keys). + * 2. `eventPublication.eventUpload` — transient event data; we iterate + * most-recent-first looking for any event whose `properties.segments` is a + * non-empty array. + * + * Returns an array of segment ID strings, or an empty array if unavailable. + */ +export function getPermutiveSegments(): string[] { + try { + const raw = localStorage.getItem('permutive-app'); + if (!raw) return []; + + const data = JSON.parse(raw); + + // Primary: core.cohorts.all (full cohort membership — numeric IDs + activation keys) + const all = data?.core?.cohorts?.all; + if (Array.isArray(all) && all.length > 0) { + log.debug('getPermutiveSegments: found segments in core.cohorts.all', { count: all.length }); + return all.filter((s: unknown) => typeof s === 'string' || typeof s === 'number').map(String).slice(0, MAX_SEGMENTS); + } + + // Fallback: eventUpload entries (transient event data) + const uploads: unknown[] = data?.eventPublication?.eventUpload; + if (Array.isArray(uploads)) { + for (let i = uploads.length - 1; i >= 0; i--) { + const entry = uploads[i]; + if (!Array.isArray(entry) || entry.length < 2) continue; + + const segments = entry[1]?.event?.properties?.segments; + if (Array.isArray(segments) && segments.length > 0) { + log.debug('getPermutiveSegments: found segments in eventUpload', { + count: segments.length, + }); + return segments + .filter((s: unknown) => typeof s === 'string' || typeof s === 'number') + .map(String) + .slice(0, MAX_SEGMENTS); + } + } + } + } catch { + log.debug('getPermutiveSegments: failed to read from localStorage'); + } + return []; +} diff --git a/crates/js/lib/test/core/context.test.ts b/crates/js/lib/test/core/context.test.ts new file mode 100644 index 00000000..74854837 --- /dev/null +++ b/crates/js/lib/test/core/context.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('context provider registry', () => { + beforeEach(async () => { + await vi.resetModules(); + }); + + it('returns empty context when no providers registered', async () => { + const { collectContext } = await import('../../src/core/context'); + expect(collectContext()).toEqual({}); + }); + + it('collects data from a single provider', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider('test', () => ({ foo: 'bar' })); + expect(collectContext()).toEqual({ foo: 'bar' }); + }); + + it('merges data from multiple providers', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider('a', () => ({ a: 1 })); + registerContextProvider('b', () => ({ b: 2 })); + expect(collectContext()).toEqual({ a: 1, b: 2 }); + }); + + it('later providers overwrite earlier ones on key collision', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider('first', () => ({ key: 'first' })); + registerContextProvider('second', () => ({ key: 'second' })); + expect(collectContext()).toEqual({ key: 'second' }); + }); + + it('skips providers that return undefined', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider('noop', () => undefined); + registerContextProvider('kept', () => ({ kept: true })); + expect(collectContext()).toEqual({ kept: true }); + }); + + it('skips providers that throw', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider('boom', () => { + throw new Error('boom'); + }); + registerContextProvider('survivor', () => ({ survived: true })); + expect(collectContext()).toEqual({ survived: true }); + }); + + it('re-registration with same id replaces previous provider', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider('dup', () => ({ v: 1 })); + registerContextProvider('dup', () => ({ v: 2 })); + expect(collectContext()).toEqual({ v: 2 }); + }); +}); diff --git a/crates/js/lib/test/integrations/permutive/segments.test.ts b/crates/js/lib/test/integrations/permutive/segments.test.ts new file mode 100644 index 00000000..66dfa2bd --- /dev/null +++ b/crates/js/lib/test/integrations/permutive/segments.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +describe('getPermutiveSegments', () => { + let getPermutiveSegments: () => string[]; + + beforeEach(async () => { + await vi.resetModules(); + localStorage.clear(); + const mod = await import('../../../src/integrations/permutive/segments'); + getPermutiveSegments = mod.getPermutiveSegments; + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('returns empty array when no permutive-app in localStorage', () => { + expect(getPermutiveSegments()).toEqual([]); + }); + + it('returns empty array when permutive-app is invalid JSON', () => { + localStorage.setItem('permutive-app', 'not-json'); + expect(getPermutiveSegments()).toEqual([]); + }); + + it('reads segments from core.cohorts.all (primary path)', () => { + localStorage.setItem( + 'permutive-app', + JSON.stringify({ + core: { cohorts: { all: ['10000001', '10000003', 'adv', 'bhgp'] } }, + }) + ); + expect(getPermutiveSegments()).toEqual(['10000001', '10000003', 'adv', 'bhgp']); + }); + + it('converts numeric cohort IDs to strings', () => { + localStorage.setItem( + 'permutive-app', + JSON.stringify({ + core: { cohorts: { all: [123, 456] } }, + }) + ); + expect(getPermutiveSegments()).toEqual(['123', '456']); + }); + + it('falls back to eventUpload when cohorts.all is missing', () => { + localStorage.setItem( + 'permutive-app', + JSON.stringify({ + eventPublication: { + eventUpload: [['key1', { event: { properties: { segments: ['seg1', 'seg2'] } } }]], + }, + }) + ); + expect(getPermutiveSegments()).toEqual(['seg1', 'seg2']); + }); + + it('reads most recent eventUpload entry first', () => { + localStorage.setItem( + 'permutive-app', + JSON.stringify({ + eventPublication: { + eventUpload: [ + ['old', { event: { properties: { segments: ['old1'] } } }], + ['new', { event: { properties: { segments: ['new1', 'new2'] } } }], + ], + }, + }) + ); + // Should return the last (most recent) entry + expect(getPermutiveSegments()).toEqual(['new1', 'new2']); + }); + + it('returns empty array when cohorts.all is empty and no eventUpload', () => { + localStorage.setItem( + 'permutive-app', + JSON.stringify({ + core: { cohorts: { all: [] } }, + }) + ); + expect(getPermutiveSegments()).toEqual([]); + }); + + it('caps segments at 100', () => { + const ids = Array.from({ length: 150 }, (_, i) => `seg-${i}`); + localStorage.setItem( + 'permutive-app', + JSON.stringify({ core: { cohorts: { all: ids } } }) + ); + const result = getPermutiveSegments(); + expect(result).toHaveLength(100); + expect(result[0]).toBe('seg-0'); + expect(result[99]).toBe('seg-99'); + }); + + it('filters out non-string non-number values', () => { + localStorage.setItem( + 'permutive-app', + JSON.stringify({ + core: { cohorts: { all: ['valid', 123, null, undefined, true, { obj: 1 }] } }, + }) + ); + expect(getPermutiveSegments()).toEqual(['valid', '123']); + }); +}); diff --git a/trusted-server.toml b/trusted-server.toml index 2e22c06c..c28cdaab 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -89,6 +89,9 @@ enabled = true providers = ["prebid"] # mediator = "adserver_mock" # will use mediator when set timeout_ms = 2000 +# Context keys the JS client is allowed to forward into auction requests. +# Keys not in this list are silently dropped. An empty list blocks all keys. +allowed_context_keys = ["permutive_segments"] [integrations.aps] enabled = false @@ -101,4 +104,10 @@ enabled = false endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" timeout_ms = 1000 +# Map auction-request context keys to mediation URL query parameters. +# Each key is a context key from the JS client; the value becomes the +# query parameter name. Arrays are joined with commas. +[integrations.adserver_mock.context_query_params] +permutive_segments = "permutive" +