Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions crates/common/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ use super::types::{
#[serde(rename_all = "camelCase")]
pub struct AdRequest {
pub ad_units: Vec<AdUnit>,
#[allow(dead_code)]
pub config: Option<JsonValue>,
}

Expand Down Expand Up @@ -135,6 +134,26 @@ 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.
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 !context.is_empty() {
log::info!(
"Auction request context: {} entries ({})",
context.len(),
context.keys().cloned().collect::<Vec<_>>().join(", ")
);
}
}
}

Ok(AuctionRequest {
id: Uuid::new_v4().to_string(),
slots,
Expand All @@ -152,7 +171,7 @@ pub fn convert_tsjs_to_auction_request(
domain: settings.publisher.domain.clone(),
page: format!("https://{}", settings.publisher.domain),
}),
context: HashMap::new(),
context,
})
}

Expand Down
120 changes: 119 additions & 1 deletion crates/common/src/integrations/adserver_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,43 @@ 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_str()
.map(String::from)
.or_else(|| v.as_u64().map(|n| n.to_string()))
})
.collect::<Vec<_>>()
.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:
Expand Down Expand Up @@ -256,8 +293,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) {
Expand Down Expand Up @@ -713,4 +753,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", "adv", "bhgp"]),
);

let url = provider.build_endpoint_url(&request);
assert_eq!(
url,
"http://localhost:6767/adserver/mediate?permutive=10000001,10000003,adv,bhgp"
);
}

#[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", "adv"]));

let url = provider.build_endpoint_url(&request);
assert_eq!(
url,
"http://localhost:6767/adserver/mediate?debug=true&permutive=123,adv"
);
}
}
41 changes: 41 additions & 0 deletions crates/js/lib/src/core/context.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown> {
const context: Record<string, unknown> = {};
for (const provider of providers) {
try {
const data = provider();
if (data) Object.assign(context, data);
} catch {
log.debug('context: provider threw, skipping');
}
}
return context;
}
8 changes: 4 additions & 4 deletions crates/js/lib/src/core/request.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions crates/js/lib/src/integrations/permutive/index.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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());
}
55 changes: 55 additions & 0 deletions crates/js/lib/src/integrations/permutive/segments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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 [];
}
48 changes: 48 additions & 0 deletions crates/js/lib/test/core/context.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading