From 4038336da1800da2765f16466a75560dccb7a6dc Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 9 Feb 2026 11:45:46 -0600 Subject: [PATCH 1/5] maybe permutive work --- crates/common/src/auction/formats.rs | 16 ++- .../common/src/integrations/adserver_mock.rs | 117 +++++++++++++++++- crates/js/lib/src/core/request.ts | 45 ++++++- 3 files changed, 174 insertions(+), 4 deletions(-) diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs index 6b446f05..abb9c480 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -135,6 +135,20 @@ pub fn convert_tsjs_to_auction_request( geo: Some(geo), }); + // Extract optional Permutive segments from the request config + let mut context = HashMap::new(); + if let Some(ref config) = body.config { + if let Some(segments) = config.get("permutive_segments") { + if segments.is_array() { + log::info!( + "Auction request includes {} Permutive segments", + segments.as_array().map_or(0, Vec::len) + ); + context.insert("permutive_segments".to_string(), segments.clone()); + } + } + } + Ok(AuctionRequest { id: Uuid::new_v4().to_string(), slots, @@ -152,7 +166,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/integrations/adserver_mock.rs b/crates/common/src/integrations/adserver_mock.rs index b84625e5..729bc4f1 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -85,6 +85,40 @@ impl AdServerMockProvider { Self { config } } + /// Build the mediation endpoint URL, appending Permutive segments as a query + /// string parameter when present in the auction request context. + /// + /// For example, if segments `[10000001, 10000003]` are present, the URL + /// becomes `https://…/adserver/mediate?permutive=10000001,10000003`. + fn build_endpoint_url(&self, request: &AuctionRequest) -> String { + if let Some(segments_val) = request.context.get("permutive_segments") { + if let Some(segments) = segments_val.as_array() { + let csv: String = segments + .iter() + .filter_map(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))) + .map(|n| n.to_string()) + .collect::>() + .join(","); + + if !csv.is_empty() { + // Append as query parameter, respecting existing query strings + let sep = if self.config.endpoint.contains('?') { + "&" + } else { + "?" + }; + let url = format!("{}{}permutive={}", self.config.endpoint, sep, csv); + log::info!( + "AdServer Mock: appending {} Permutive segments to mediation URL", + segments.len() + ); + return url; + } + } + } + self.config.endpoint.clone() + } + /// Build mediation request from auction request and bidder responses. /// /// Handles both: @@ -256,8 +290,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) { @@ -713,4 +750,82 @@ mod tests { "Bid without price field should have None price" ); } + + #[test] + fn test_build_endpoint_url_with_permutive_segments() { + let config = AdServerMockConfig { + enabled: true, + endpoint: "http://localhost:6767/adserver/mediate".to_string(), + timeout_ms: 500, + price_floor: None, + }; + let provider = AdServerMockProvider::new(config); + + let mut request = create_test_auction_request(); + request.context.insert( + "permutive_segments".to_string(), + json!([10000001, 10000003, 10000008]), + ); + + let url = provider.build_endpoint_url(&request); + assert_eq!( + url, + "http://localhost:6767/adserver/mediate?permutive=10000001,10000003,10000008" + ); + } + + #[test] + fn test_build_endpoint_url_without_segments() { + let config = AdServerMockConfig { + enabled: true, + endpoint: "http://localhost:6767/adserver/mediate".to_string(), + timeout_ms: 500, + price_floor: None, + }; + let provider = AdServerMockProvider::new(config); + + let request = create_test_auction_request(); + let url = provider.build_endpoint_url(&request); + assert_eq!(url, "http://localhost:6767/adserver/mediate"); + } + + #[test] + fn test_build_endpoint_url_with_empty_segments() { + let config = AdServerMockConfig::default(); + let provider = AdServerMockProvider::new(config); + + let mut request = create_test_auction_request(); + request + .context + .insert("permutive_segments".to_string(), json!([])); + + let url = provider.build_endpoint_url(&request); + // Empty segments array should NOT append query param + 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, + }; + let provider = AdServerMockProvider::new(config); + + let mut request = create_test_auction_request(); + request + .context + .insert("permutive_segments".to_string(), json!([123, 456])); + + let url = provider.build_endpoint_url(&request); + assert_eq!( + url, + "http://localhost:6767/adserver/mediate?debug=true&permutive=123,456" + ); + } } diff --git a/crates/js/lib/src/core/request.ts b/crates/js/lib/src/core/request.ts index a84af082..0dbb6244 100644 --- a/crates/js/lib/src/core/request.ts +++ b/crates/js/lib/src/core/request.ts @@ -6,6 +6,42 @@ import type { RequestAdsCallback, RequestAdsOptions } from './types'; // getHighestCpmBids is provided by the Prebid extension (shim) to mirror Prebid's API +/** + * Read Permutive segment IDs from localStorage. + * + * Permutive stores event data in the `permutive-app` key. Each event entry in + * `eventPublication.eventUpload` is a tuple of [eventKey, eventObject]. We + * iterate in reverse (most recent first) looking for any event whose + * `properties.segments` is a non-empty array. + * + * Returns an array of segment ID numbers, or an empty array if unavailable. + */ +function getPermutiveSegments(): number[] { + try { + const raw = localStorage.getItem('permutive-app'); + if (!raw) return []; + + const data = JSON.parse(raw); + const uploads: unknown[] = data?.eventPublication?.eventUpload; + if (!Array.isArray(uploads) || uploads.length === 0) return []; + + // Iterate most-recent-first to get the freshest segments + 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', { count: segments.length }); + return segments.filter((s: unknown) => typeof s === 'number') as number[]; + } + } + } catch { + log.debug('getPermutiveSegments: failed to read from localStorage'); + } + return []; +} + // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, @@ -24,8 +60,13 @@ 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 permutiveSegments = getPermutiveSegments(); + const config: Record = {}; + if (permutiveSegments.length > 0) { + config.permutive_segments = permutiveSegments; + } + const payload = { adUnits, config }; + log.debug('requestAds: payload', { units: adUnits.length, permutiveSegments: permutiveSegments.length }); // Use unified auction endpoint void requestAdsUnified(payload); From 801b51ddc3784d4cd04de0f0e55fbf346fb0638d Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 9 Feb 2026 13:17:40 -0600 Subject: [PATCH 2/5] refactors --- crates/common/src/auction/formats.rs | 19 ++-- .../common/src/integrations/adserver_mock.rs | 15 +-- crates/js/lib/src/core/context.ts | 41 ++++++++ crates/js/lib/src/core/request.ts | 47 +-------- .../lib/src/integrations/permutive/index.ts | 10 ++ .../src/integrations/permutive/segments.ts | 57 +++++++++++ crates/js/lib/test/core/context.test.ts | 48 ++++++++++ .../integrations/permutive/segments.test.ts | 95 +++++++++++++++++++ 8 files changed, 275 insertions(+), 57 deletions(-) create mode 100644 crates/js/lib/src/core/context.ts create mode 100644 crates/js/lib/src/integrations/permutive/segments.ts create mode 100644 crates/js/lib/test/core/context.test.ts create mode 100644 crates/js/lib/test/integrations/permutive/segments.test.ts diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs index abb9c480..3815cbd6 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -30,7 +30,6 @@ use super::types::{ #[serde(rename_all = "camelCase")] pub struct AdRequest { pub ad_units: Vec, - #[allow(dead_code)] pub config: Option, } @@ -135,16 +134,22 @@ pub fn convert_tsjs_to_auction_request( geo: Some(geo), }); - // Extract optional Permutive segments from the request config + // Forward all config entries from the JS request into the context map. + // Each integration's context provider contributes its own keys (e.g. + // permutive_segments, lockr_ids, …) — we pass them all through so + // auction providers can read whatever they need. let mut context = HashMap::new(); if let Some(ref config) = body.config { - if let Some(segments) = config.get("permutive_segments") { - if segments.is_array() { + if let Some(obj) = config.as_object() { + for (key, value) in obj { + context.insert(key.clone(), value.clone()); + } + if !context.is_empty() { log::info!( - "Auction request includes {} Permutive segments", - segments.as_array().map_or(0, Vec::len) + "Auction request context: {} entries ({})", + context.len(), + context.keys().cloned().collect::>().join(", ") ); - context.insert("permutive_segments".to_string(), segments.clone()); } } } diff --git a/crates/common/src/integrations/adserver_mock.rs b/crates/common/src/integrations/adserver_mock.rs index 729bc4f1..a088afb7 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -95,8 +95,11 @@ impl AdServerMockProvider { if let Some(segments) = segments_val.as_array() { let csv: String = segments .iter() - .filter_map(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))) - .map(|n| n.to_string()) + .filter_map(|v| { + v.as_str() + .map(String::from) + .or_else(|| v.as_u64().map(|n| n.to_string())) + }) .collect::>() .join(","); @@ -764,13 +767,13 @@ mod tests { let mut request = create_test_auction_request(); request.context.insert( "permutive_segments".to_string(), - json!([10000001, 10000003, 10000008]), + json!(["10000001", "10000003", "adv", "bhgp"]), ); let url = provider.build_endpoint_url(&request); assert_eq!( url, - "http://localhost:6767/adserver/mediate?permutive=10000001,10000003,10000008" + "http://localhost:6767/adserver/mediate?permutive=10000001,10000003,adv,bhgp" ); } @@ -820,12 +823,12 @@ mod tests { let mut request = create_test_auction_request(); request .context - .insert("permutive_segments".to_string(), json!([123, 456])); + .insert("permutive_segments".to_string(), json!(["123", "adv"])); let url = provider.build_endpoint_url(&request); assert_eq!( url, - "http://localhost:6767/adserver/mediate?debug=true&permutive=123,456" + "http://localhost:6767/adserver/mediate?debug=true&permutive=123,adv" ); } } diff --git a/crates/js/lib/src/core/context.ts b/crates/js/lib/src/core/context.ts new file mode 100644 index 00000000..49e9c012 --- /dev/null +++ b/crates/js/lib/src/core/context.ts @@ -0,0 +1,41 @@ +// 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: ContextProvider[] = []; + +/** + * 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. + */ +export function registerContextProvider(provider: ContextProvider): void { + providers.push(provider); + log.debug('context: registered provider', { total: providers.length }); +} + +/** + * 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) { + 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 0dbb6244..1d5e4e08 100644 --- a/crates/js/lib/src/core/request.ts +++ b/crates/js/lib/src/core/request.ts @@ -1,47 +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 - -/** - * Read Permutive segment IDs from localStorage. - * - * Permutive stores event data in the `permutive-app` key. Each event entry in - * `eventPublication.eventUpload` is a tuple of [eventKey, eventObject]. We - * iterate in reverse (most recent first) looking for any event whose - * `properties.segments` is a non-empty array. - * - * Returns an array of segment ID numbers, or an empty array if unavailable. - */ -function getPermutiveSegments(): number[] { - try { - const raw = localStorage.getItem('permutive-app'); - if (!raw) return []; - - const data = JSON.parse(raw); - const uploads: unknown[] = data?.eventPublication?.eventUpload; - if (!Array.isArray(uploads) || uploads.length === 0) return []; - - // Iterate most-recent-first to get the freshest segments - 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', { count: segments.length }); - return segments.filter((s: unknown) => typeof s === 'number') as number[]; - } - } - } catch { - log.debug('getPermutiveSegments: failed to read from localStorage'); - } - return []; -} - // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, @@ -60,13 +23,9 @@ export function requestAds( log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); try { const adUnits = getAllUnits(); - const permutiveSegments = getPermutiveSegments(); - const config: Record = {}; - if (permutiveSegments.length > 0) { - config.permutive_segments = permutiveSegments; - } + const config = collectContext(); const payload = { adUnits, config }; - log.debug('requestAds: payload', { units: adUnits.length, permutiveSegments: permutiveSegments.length }); + 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..e3f56269 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(() => { + 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..a24d8e6c --- /dev/null +++ b/crates/js/lib/src/integrations/permutive/segments.ts @@ -0,0 +1,57 @@ +// 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'; + +/** + * 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); + } + + // 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); + } + } + } + } 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..950d9e01 --- /dev/null +++ b/crates/js/lib/test/core/context.test.ts @@ -0,0 +1,48 @@ +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(() => ({ foo: 'bar' })); + expect(collectContext()).toEqual({ foo: 'bar' }); + }); + + it('merges data from multiple providers', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider(() => ({ a: 1 })); + registerContextProvider(() => ({ 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(() => ({ key: 'first' })); + registerContextProvider(() => ({ key: 'second' })); + expect(collectContext()).toEqual({ key: 'second' }); + }); + + it('skips providers that return undefined', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider(() => undefined); + registerContextProvider(() => ({ kept: true })); + expect(collectContext()).toEqual({ kept: true }); + }); + + it('skips providers that throw', async () => { + const { registerContextProvider, collectContext } = await import('../../src/core/context'); + registerContextProvider(() => { + throw new Error('boom'); + }); + registerContextProvider(() => ({ survived: true })); + expect(collectContext()).toEqual({ survived: true }); + }); +}); 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..c1723541 --- /dev/null +++ b/crates/js/lib/test/integrations/permutive/segments.test.ts @@ -0,0 +1,95 @@ +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('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']); + }); +}); From 9d56491454d19c8b63119a37e87a42839bfa9448 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 11 Feb 2026 09:42:24 -0600 Subject: [PATCH 3/5] typescript format --- .../lib/src/integrations/permutive/segments.ts | 4 +--- .../test/integrations/permutive/segments.test.ts | 16 +++++++--------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/js/lib/src/integrations/permutive/segments.ts b/crates/js/lib/src/integrations/permutive/segments.ts index a24d8e6c..be404f00 100644 --- a/crates/js/lib/src/integrations/permutive/segments.ts +++ b/crates/js/lib/src/integrations/permutive/segments.ts @@ -27,9 +27,7 @@ export function getPermutiveSegments(): string[] { 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); + return all.filter((s: unknown) => typeof s === 'string' || typeof s === 'number').map(String); } // Fallback: eventUpload entries (transient event data) diff --git a/crates/js/lib/test/integrations/permutive/segments.test.ts b/crates/js/lib/test/integrations/permutive/segments.test.ts index c1723541..47acfb9c 100644 --- a/crates/js/lib/test/integrations/permutive/segments.test.ts +++ b/crates/js/lib/test/integrations/permutive/segments.test.ts @@ -28,7 +28,7 @@ describe('getPermutiveSegments', () => { 'permutive-app', JSON.stringify({ core: { cohorts: { all: ['10000001', '10000003', 'adv', 'bhgp'] } }, - }), + }) ); expect(getPermutiveSegments()).toEqual(['10000001', '10000003', 'adv', 'bhgp']); }); @@ -38,7 +38,7 @@ describe('getPermutiveSegments', () => { 'permutive-app', JSON.stringify({ core: { cohorts: { all: [123, 456] } }, - }), + }) ); expect(getPermutiveSegments()).toEqual(['123', '456']); }); @@ -48,11 +48,9 @@ describe('getPermutiveSegments', () => { 'permutive-app', JSON.stringify({ eventPublication: { - eventUpload: [ - ['key1', { event: { properties: { segments: ['seg1', 'seg2'] } } }], - ], + eventUpload: [['key1', { event: { properties: { segments: ['seg1', 'seg2'] } } }]], }, - }), + }) ); expect(getPermutiveSegments()).toEqual(['seg1', 'seg2']); }); @@ -67,7 +65,7 @@ describe('getPermutiveSegments', () => { ['new', { event: { properties: { segments: ['new1', 'new2'] } } }], ], }, - }), + }) ); // Should return the last (most recent) entry expect(getPermutiveSegments()).toEqual(['new1', 'new2']); @@ -78,7 +76,7 @@ describe('getPermutiveSegments', () => { 'permutive-app', JSON.stringify({ core: { cohorts: { all: [] } }, - }), + }) ); expect(getPermutiveSegments()).toEqual([]); }); @@ -88,7 +86,7 @@ describe('getPermutiveSegments', () => { 'permutive-app', JSON.stringify({ core: { cohorts: { all: ['valid', 123, null, undefined, true, { obj: 1 }] } }, - }), + }) ); expect(getPermutiveSegments()).toEqual(['valid', '123']); }); From 66f41609a0dd98376c2f2cc2bb6dcb491ff7660a Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 13 Feb 2026 14:07:20 -0600 Subject: [PATCH 4/5] address pr comments --- crates/common/src/auction/formats.rs | 17 +++++--- crates/common/src/auction/orchestrator.rs | 1 + crates/common/src/auction_config_types.rs | 26 +++++++++++- .../common/src/integrations/adserver_mock.rs | 28 ++++++------- crates/common/src/settings.rs | 42 +++++++++++++++++++ crates/js/lib/src/core/context.ts | 13 +++--- .../lib/src/integrations/permutive/index.ts | 2 +- .../src/integrations/permutive/segments.ts | 8 +++- crates/js/lib/test/core/context.test.ts | 25 +++++++---- .../integrations/permutive/segments.test.ts | 12 ++++++ trusted-server.toml | 3 ++ 11 files changed, 139 insertions(+), 38 deletions(-) diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs index 3815cbd6..5fe14e6f 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -134,18 +134,23 @@ pub fn convert_tsjs_to_auction_request( geo: Some(geo), }); - // Forward all config entries from the JS request into the context map. - // Each integration's context provider contributes its own keys (e.g. - // permutive_segments, lockr_ids, …) — we pass them all through so - // auction providers can read whatever they need. + // 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 = &settings.auction.allowed_context_keys; let mut context = HashMap::new(); if let Some(ref config) = body.config { if let Some(obj) = config.as_object() { for (key, value) in obj { - context.insert(key.clone(), value.clone()); + if allowed.iter().any(|k| k == key) { + context.insert(key.clone(), value.clone()); + } else { + log::debug!("Auction context: dropping disallowed key '{}'", key); + } } if !context.is_empty() { - log::info!( + log::debug!( "Auction request context: {} entries ({})", context.len(), context.keys().cloned().collect::>().join(", ") 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_config_types.rs b/crates/common/src/auction_config_types.rs index 916e839f..b5f7f426 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!["permutive_segments".to_string()] +} + #[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 a088afb7..f5d1c1fe 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -104,18 +104,18 @@ impl AdServerMockProvider { .join(","); if !csv.is_empty() { - // Append as query parameter, respecting existing query strings - let sep = if self.config.endpoint.contains('?') { - "&" - } else { - "?" - }; - let url = format!("{}{}permutive={}", self.config.endpoint, sep, csv); - log::info!( - "AdServer Mock: appending {} Permutive segments to mediation URL", - segments.len() - ); - return url; + // Use url::Url to safely encode the query parameter value, + // preventing injection from unescaped segment values. + if let Ok(mut url) = url::Url::parse(&self.config.endpoint) { + url.query_pairs_mut().append_pair("permutive", &csv); + log::info!( + "AdServer Mock: appending {} Permutive segments to mediation URL", + segments.len() + ); + return url.to_string(); + } + // Fall through if URL parsing fails (shouldn't happen with valid config) + log::warn!("AdServer Mock: failed to parse endpoint URL, skipping segments"); } } } @@ -773,7 +773,7 @@ mod tests { let url = provider.build_endpoint_url(&request); assert_eq!( url, - "http://localhost:6767/adserver/mediate?permutive=10000001,10000003,adv,bhgp" + "http://localhost:6767/adserver/mediate?permutive=10000001%2C10000003%2Cadv%2Cbhgp" ); } @@ -828,7 +828,7 @@ mod tests { let url = provider.build_endpoint_url(&request); assert_eq!( url, - "http://localhost:6767/adserver/mediate?debug=true&permutive=123,adv" + "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..e592b0d0 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -987,4 +987,46 @@ mod tests { assert!(!rewrite.is_excluded("not a url")); assert!(!rewrite.is_excluded("")); } + + #[test] + fn test_auction_allowed_context_keys_defaults_to_permutive_segments() { + let settings = create_test_settings(); + assert_eq!( + settings.auction.allowed_context_keys, + vec!["permutive_segments"], + "Default allowed_context_keys should contain permutive_segments" + ); + } + + #[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 index 49e9c012..9ee4ddff 100644 --- a/crates/js/lib/src/core/context.ts +++ b/crates/js/lib/src/core/context.ts @@ -8,16 +8,19 @@ import { log } from './log'; */ export type ContextProvider = () => Record | undefined; -const providers: ContextProvider[] = []; +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(provider: ContextProvider): void { - providers.push(provider); - log.debug('context: registered provider', { total: providers.length }); +export function registerContextProvider(id: string, provider: ContextProvider): void { + providers.set(id, provider); + log.debug('context: registered provider', { id, total: providers.size }); } /** @@ -29,7 +32,7 @@ export function registerContextProvider(provider: ContextProvider): void { */ export function collectContext(): Record { const context: Record = {}; - for (const provider of providers) { + for (const provider of providers.values()) { try { const data = provider(); if (data) Object.assign(context, data); diff --git a/crates/js/lib/src/integrations/permutive/index.ts b/crates/js/lib/src/integrations/permutive/index.ts index e3f56269..60eb5134 100644 --- a/crates/js/lib/src/integrations/permutive/index.ts +++ b/crates/js/lib/src/integrations/permutive/index.ts @@ -105,7 +105,7 @@ if (typeof window !== 'undefined') { // 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(() => { + registerContextProvider('permutive', () => { const segments = getPermutiveSegments(); return segments.length > 0 ? { permutive_segments: segments } : undefined; }); diff --git a/crates/js/lib/src/integrations/permutive/segments.ts b/crates/js/lib/src/integrations/permutive/segments.ts index be404f00..0ac965b5 100644 --- a/crates/js/lib/src/integrations/permutive/segments.ts +++ b/crates/js/lib/src/integrations/permutive/segments.ts @@ -3,6 +3,9 @@ // 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. * @@ -27,7 +30,7 @@ export function getPermutiveSegments(): string[] { 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); + return all.filter((s: unknown) => typeof s === 'string' || typeof s === 'number').map(String).slice(0, MAX_SEGMENTS); } // Fallback: eventUpload entries (transient event data) @@ -44,7 +47,8 @@ export function getPermutiveSegments(): string[] { }); return segments .filter((s: unknown) => typeof s === 'string' || typeof s === 'number') - .map(String); + .map(String) + .slice(0, MAX_SEGMENTS); } } } diff --git a/crates/js/lib/test/core/context.test.ts b/crates/js/lib/test/core/context.test.ts index 950d9e01..74854837 100644 --- a/crates/js/lib/test/core/context.test.ts +++ b/crates/js/lib/test/core/context.test.ts @@ -12,37 +12,44 @@ describe('context provider registry', () => { it('collects data from a single provider', async () => { const { registerContextProvider, collectContext } = await import('../../src/core/context'); - registerContextProvider(() => ({ foo: 'bar' })); + 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: 1 })); - registerContextProvider(() => ({ b: 2 })); + 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(() => ({ key: 'first' })); - registerContextProvider(() => ({ key: 'second' })); + 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(() => undefined); - registerContextProvider(() => ({ kept: true })); + 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(() => { + registerContextProvider('boom', () => { throw new Error('boom'); }); - registerContextProvider(() => ({ survived: true })); + 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 index 47acfb9c..66dfa2bd 100644 --- a/crates/js/lib/test/integrations/permutive/segments.test.ts +++ b/crates/js/lib/test/integrations/permutive/segments.test.ts @@ -81,6 +81,18 @@ describe('getPermutiveSegments', () => { 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', diff --git a/trusted-server.toml b/trusted-server.toml index 2e22c06c..496fe175 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 From 436af724c6946e158ba24d16c8d2c3d56c31a6a7 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 13 Feb 2026 14:20:30 -0600 Subject: [PATCH 5/5] decouple permutive to adserver --- crates/common/src/auction/context.rs | 205 ++++++++++++++++++ crates/common/src/auction/mod.rs | 2 + .../common/src/integrations/adserver_mock.rs | 89 ++++---- trusted-server.toml | 6 + 4 files changed, 263 insertions(+), 39 deletions(-) create mode 100644 crates/common/src/auction/context.rs diff --git a/crates/common/src/auction/context.rs b/crates/common/src/auction/context.rs new file mode 100644 index 00000000..6f616ff8 --- /dev/null +++ b/crates/common/src/auction/context.rs @@ -0,0 +1,205 @@ +//! 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 std::collections::HashMap; + +/// 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 = HashMap; + +/// 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 [`serde_json::Value`] into a string suitable for a query +/// parameter value. Arrays are joined with commas; strings and numbers are +/// returned directly; anything else yields an empty string (skipped). +fn serialize_context_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .collect::>() + .join(","), + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_build_url_with_context_params_appends_array() { + let context = HashMap::from([( + "permutive_segments".to_string(), + json!(["10000001", "10000003", "adv"]), + )]); + let mapping = HashMap::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(), json!(["123", "adv"]))]); + let mapping = HashMap::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 = HashMap::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(), json!([]))]); + let mapping = HashMap::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(), json!(["seg1"])), + ("lockr_ids".to_string(), json!("lockr-abc-123")), + ]); + let mapping = HashMap::from([ + ("permutive_segments".to_string(), "permutive".to_string()), + ("lockr_ids".to_string(), "lockr".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(), json!(42))]); + let mapping = HashMap::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_array() { + assert_eq!(serialize_context_value(&json!(["a", "b", 3])), "a,b,3"); + } + + #[test] + fn test_serialize_context_value_string() { + assert_eq!(serialize_context_value(&json!("hello")), "hello"); + } + + #[test] + fn test_serialize_context_value_number() { + assert_eq!(serialize_context_value(&json!(99)), "99"); + } + + #[test] + fn test_serialize_context_value_object_returns_empty() { + assert_eq!(serialize_context_value(&json!({"a": 1})), ""); + } +} diff --git a/crates/common/src/auction/mod.rs b/crates/common/src/auction/mod.rs index e78f2ecf..9175071e 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}; pub use orchestrator::AuctionOrchestrator; pub use provider::AuctionProvider; pub use types::{ diff --git a/crates/common/src/integrations/adserver_mock.rs b/crates/common/src/integrations/adserver_mock.rs index f5d1c1fe..5d1082da 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -13,6 +13,7 @@ use std::collections::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: HashMap::new(), } } } @@ -85,41 +98,18 @@ impl AdServerMockProvider { Self { config } } - /// Build the mediation endpoint URL, appending Permutive segments as a query - /// string parameter when present in the auction request context. + /// Build the mediation endpoint URL, appending context values as query + /// parameters according to the `context_query_params` config mapping. /// - /// For example, if segments `[10000001, 10000003]` are present, the URL - /// becomes `https://…/adserver/mediate?permutive=10000001,10000003`. + /// 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 { - if let Some(segments_val) = request.context.get("permutive_segments") { - if let Some(segments) = segments_val.as_array() { - let csv: String = segments - .iter() - .filter_map(|v| { - v.as_str() - .map(String::from) - .or_else(|| v.as_u64().map(|n| n.to_string())) - }) - .collect::>() - .join(","); - - if !csv.is_empty() { - // Use url::Url to safely encode the query parameter value, - // preventing injection from unescaped segment values. - if let Ok(mut url) = url::Url::parse(&self.config.endpoint) { - url.query_pairs_mut().append_pair("permutive", &csv); - log::info!( - "AdServer Mock: appending {} Permutive segments to mediation URL", - segments.len() - ); - return url.to_string(); - } - // Fall through if URL parsing fails (shouldn't happen with valid config) - log::warn!("AdServer Mock: failed to parse endpoint URL, skipping segments"); - } - } - } - self.config.endpoint.clone() + build_url_with_context_params( + &self.config.endpoint, + &request.context, + &self.config.context_query_params, + ) } /// Build mediation request from auction request and bidder responses. @@ -461,6 +451,7 @@ mod tests { endpoint: "http://localhost:6767/adserver/mediate".to_string(), timeout_ms: 500, price_floor: Some(1.00), + context_query_params: HashMap::new(), }; let provider = AdServerMockProvider::new(config); @@ -755,12 +746,16 @@ mod tests { } #[test] - fn test_build_endpoint_url_with_permutive_segments() { + 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: HashMap::from([( + "permutive_segments".to_string(), + "permutive".to_string(), + )]), }; let provider = AdServerMockProvider::new(config); @@ -778,23 +773,36 @@ mod tests { } #[test] - fn test_build_endpoint_url_without_segments() { + 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: HashMap::new(), }; let provider = AdServerMockProvider::new(config); - let request = create_test_auction_request(); + let mut request = create_test_auction_request(); + request + .context + .insert("permutive_segments".to_string(), json!(["10000001"])); + let url = provider.build_endpoint_url(&request); assert_eq!(url, "http://localhost:6767/adserver/mediate"); } #[test] - fn test_build_endpoint_url_with_empty_segments() { - let config = AdServerMockConfig::default(); + fn test_build_endpoint_url_empty_array_skipped() { + let config = AdServerMockConfig { + context_query_params: HashMap::from([( + "permutive_segments".to_string(), + "permutive".to_string(), + )]), + ..Default::default() + }; let provider = AdServerMockProvider::new(config); let mut request = create_test_auction_request(); @@ -803,7 +811,6 @@ mod tests { .insert("permutive_segments".to_string(), json!([])); let url = provider.build_endpoint_url(&request); - // Empty segments array should NOT append query param assert!( !url.contains("permutive="), "Empty segments should not add query param" @@ -817,6 +824,10 @@ mod tests { endpoint: "http://localhost:6767/adserver/mediate?debug=true".to_string(), timeout_ms: 500, price_floor: None, + context_query_params: HashMap::from([( + "permutive_segments".to_string(), + "permutive".to_string(), + )]), }; let provider = AdServerMockProvider::new(config); diff --git a/trusted-server.toml b/trusted-server.toml index 496fe175..c28cdaab 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -104,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" +