Skip to content
Draft
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
4 changes: 2 additions & 2 deletions SEQUENCE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 🛡️ Trusted Server — First-Party Proxying Flow
# 🛡️ Trusted Server — Proxying Flow

## 🔄 System Flow Diagram

Expand Down Expand Up @@ -80,7 +80,7 @@ sequenceDiagram
activate TS
activate PBS
activate DSP
JS->>TS: GET /first-party/ad<br/>(with signals)
JS->>TS: GET /ad/render<br/>(with signals)
TS->>PBS: POST /openrtb2/auction<br/>(OpenRTB 2.x)
PBS->>DSP: POST bid request
DSP-->>PBS: 200 bid response
Expand Down
2 changes: 1 addition & 1 deletion crates/common/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# trusted-server-common

Utilities shared by Trusted Server components. This crate contains HTML/CSS rewriting helpers used to normalize ad creative assets to first‑party proxy endpoints.
Utilities shared by Trusted Server components. This crate contains HTML/CSS rewriting helpers used to normalize ad creative assets to proxy endpoints.

## Creative Rewriting

Expand Down
73 changes: 70 additions & 3 deletions crates/common/src/html_processor.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Simplified HTML processor that combines URL replacement and Prebid injection
//! Simplified HTML processor that combines URL replacement and integration injection
//!
//! This module provides a StreamProcessor implementation for HTML content.
use std::cell::Cell;
Expand Down Expand Up @@ -189,10 +189,23 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
// Inject unified tsjs bundle once at the start of <head>
element!("head", {
let injected_tsjs = injected_tsjs.clone();
let integrations = integration_registry.clone();
let patterns = patterns.clone();
let document_state = document_state.clone();
move |el| {
if !injected_tsjs.get() {
let loader = tsjs::unified_script_tag();
el.prepend(&loader, ContentType::Html);
let mut snippet = String::new();
let ctx = IntegrationHtmlContext {
request_host: &patterns.request_host,
request_scheme: &patterns.request_scheme,
origin_host: &patterns.origin_host,
document_state: &document_state,
};
for insert in integrations.head_inserts(&ctx) {
snippet.push_str(&insert);
}
snippet.push_str(&tsjs::unified_script_tag());
el.prepend(&snippet, ContentType::Html);
injected_tsjs.set(true);
}
Ok(())
Expand Down Expand Up @@ -454,8 +467,10 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
#[cfg(test)]
mod tests {
use super::*;
use crate::integrations::prebid::{config_script_tag, Mode as PrebidMode};
use crate::integrations::{
AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter,
IntegrationHeadInjector,
};
use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline};
use crate::test_support::tests::create_test_settings;
Expand Down Expand Up @@ -582,6 +597,58 @@ mod tests {
assert_eq!(config.request_scheme, "https");
}

#[test]
fn injects_tsjs_config_when_mode_set() {
struct ModeInjector;

impl IntegrationHeadInjector for ModeInjector {
fn integration_id(&self) -> &'static str {
"mode-injector"
}

fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
vec![config_script_tag(PrebidMode::Auction)]
}
}

let mut config = create_test_config();
config.integrations = IntegrationRegistry::from_rewriters_with_head_injectors(
Vec::new(),
Vec::new(),
vec![Arc::new(ModeInjector)],
);
let processor = create_html_processor(config);

let pipeline_config = PipelineConfig {
input_compression: Compression::None,
output_compression: Compression::None,
chunk_size: 8192,
};
let mut pipeline = StreamingPipeline::new(pipeline_config, processor);

let html = "<html><head></head><body></body></html>";
let mut output = Vec::new();
pipeline
.process(Cursor::new(html.as_bytes()), &mut output)
.unwrap();
let result = String::from_utf8(output).unwrap();

let config_pos = result.find("setConfig({mode:\"auction\"})");
let script_pos = result.find("trustedserver-js");
assert!(
config_pos.is_some(),
"should inject tsjs mode config when configured"
);
assert!(
script_pos.is_some(),
"should inject unified tsjs script when processing HTML"
);
assert!(
config_pos.unwrap() < script_pos.unwrap(),
"should place tsjs config before the unified bundle"
);
}

#[test]
fn test_real_publisher_html() {
// Test with publisher HTML from test_publisher.html
Expand Down
6 changes: 3 additions & 3 deletions crates/common/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ pub mod testlight;
pub use registry::{
AttributeRewriteAction, AttributeRewriteOutcome, IntegrationAttributeContext,
IntegrationAttributeRewriter, IntegrationDocumentState, IntegrationEndpoint,
IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationMetadata, IntegrationProxy,
IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry,
IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction,
IntegrationHeadInjector, IntegrationHtmlContext, IntegrationHtmlPostProcessor,
IntegrationMetadata, IntegrationProxy, IntegrationRegistration, IntegrationRegistrationBuilder,
IntegrationRegistry, IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction,
};

type IntegrationBuilder = fn(&Settings) -> Option<IntegrationRegistration>;
Expand Down
126 changes: 105 additions & 21 deletions crates/common/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,35 @@ use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::integrations::{
AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter,
IntegrationEndpoint, IntegrationProxy, IntegrationRegistration,
IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy,
IntegrationRegistration,
};
use crate::openrtb::{Banner, Format, Imp, ImpExt, OpenRtbRequest, PrebidImpExt, Site};
use crate::request_signing::RequestSigner;
use crate::settings::{IntegrationConfig, Settings};
use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id};

const PREBID_INTEGRATION_ID: &str = "prebid";
const ROUTE_FIRST_PARTY_AD: &str = "/first-party/ad";
const ROUTE_THIRD_PARTY_AD: &str = "/third-party/ad";
const ROUTE_RENDER: &str = "/ad/render";
const ROUTE_AUCTION: &str = "/ad/auction";

#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Render,
Auction,
}

pub fn config_script_tag(mode: Mode) -> String {
let mode_value = match mode {
Mode::Render => "render",
Mode::Auction => "auction",
};
format!(
r#"<script>window.tsjs=window.tsjs||{{que:[]}};tsjs.que.push(function(){{tsjs.setConfig({{mode:"{}"}});}});</script>"#,
mode_value
)
}

#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
pub struct PrebidIntegrationConfig {
Expand All @@ -43,6 +62,9 @@ pub struct PrebidIntegrationConfig {
pub bidders: Vec<String>,
#[serde(default)]
pub debug: bool,
/// Optional default mode to enqueue when injecting the unified bundle.
#[serde(default)]
pub mode: Option<Mode>,
/// Patterns to match Prebid script URLs for serving empty JS.
/// Supports suffix matching (e.g., "/prebid.min.js" matches any path ending with that)
/// and wildcard patterns (e.g., "/static/prebid/*" matches paths under that prefix).
Expand Down Expand Up @@ -212,7 +234,7 @@ impl PrebidIntegration {
}
}

async fn handle_third_party_ad(
async fn handle_auction(
&self,
settings: &Settings,
mut req: Request,
Expand All @@ -223,7 +245,7 @@ impl PrebidIntegration {
},
)?;

log::info!("/third-party/ad: received {} adUnits", body.ad_units.len());
log::info!("/auction: received {} adUnits", body.ad_units.len());
for unit in &body.ad_units {
if let Some(mt) = &unit.media_types {
if let Some(banner) = &mt.banner {
Expand Down Expand Up @@ -260,14 +282,14 @@ impl PrebidIntegration {
.with_body(body))
}

async fn handle_first_party_ad(
async fn handle_render(
&self,
settings: &Settings,
mut req: Request,
) -> Result<Response, Report<TrustedServerError>> {
let url = req.get_url_str();
let parsed = Url::parse(url).change_context(TrustedServerError::Prebid {
message: "Invalid first-party serve-ad URL".to_string(),
message: "Invalid render URL".to_string(),
})?;
let qp = parsed
.query_pairs()
Expand Down Expand Up @@ -344,7 +366,8 @@ pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
Some(
IntegrationRegistration::builder(PREBID_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration)
.with_attribute_rewriter(integration.clone())
.with_head_injector(integration)
.build(),
)
}
Expand All @@ -357,8 +380,8 @@ impl IntegrationProxy for PrebidIntegration {

fn routes(&self) -> Vec<IntegrationEndpoint> {
let mut routes = vec![
IntegrationEndpoint::get(ROUTE_FIRST_PARTY_AD),
IntegrationEndpoint::post(ROUTE_THIRD_PARTY_AD),
IntegrationEndpoint::get(ROUTE_RENDER),
IntegrationEndpoint::post(ROUTE_AUCTION),
];

// Register routes for script removal patterns
Expand All @@ -381,12 +404,8 @@ impl IntegrationProxy for PrebidIntegration {
let method = req.get_method().clone();

match method {
Method::GET if path == ROUTE_FIRST_PARTY_AD => {
self.handle_first_party_ad(settings, req).await
}
Method::POST if path == ROUTE_THIRD_PARTY_AD => {
self.handle_third_party_ad(settings, req).await
}
Method::GET if path == ROUTE_RENDER => self.handle_render(settings, req).await,
Method::POST if path == ROUTE_AUCTION => self.handle_auction(settings, req).await,
// Serve empty JS for matching script patterns
Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(),
_ => Err(Report::new(Self::error(format!(
Expand Down Expand Up @@ -419,6 +438,19 @@ impl IntegrationAttributeRewriter for PrebidIntegration {
}
}

impl IntegrationHeadInjector for PrebidIntegration {
fn integration_id(&self) -> &'static str {
PREBID_INTEGRATION_ID
}

fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
self.config
.mode
.map(|mode| vec![config_script_tag(mode)])
.unwrap_or_default()
}
}

fn build_openrtb_from_ts(
req: &AdRequest,
settings: &Settings,
Expand Down Expand Up @@ -796,6 +828,7 @@ mod tests {
timeout_ms: 1000,
bidders: vec!["exampleBidder".to_string()],
debug: false,
mode: None,
script_patterns: default_script_patterns(),
}
}
Expand Down Expand Up @@ -946,8 +979,8 @@ mod tests {
let routes = integration.routes();

// Should include the default ad routes
assert!(routes.iter().any(|r| r.path == "/first-party/ad"));
assert!(routes.iter().any(|r| r.path == "/third-party/ad"));
assert!(routes.iter().any(|r| r.path == "/ad/render"));
assert!(routes.iter().any(|r| r.path == "/ad/auction"));

// Should include default script removal patterns
assert!(routes.iter().any(|r| r.path == "/prebid.js"));
Expand All @@ -965,7 +998,7 @@ mod tests {

let synthetic_id = "synthetic-123";
let fresh_id = "fresh-456";
let mut req = Request::new(Method::POST, "https://edge.example/third-party/ad");
let mut req = Request::new(Method::POST, "https://edge.example/auction");
req.set_header("Sec-GPC", "1");

enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req)
Expand Down Expand Up @@ -1158,13 +1191,64 @@ server_url = "https://prebid.example"
assert_eq!(routes.len(), 6);

// Verify ad routes
assert!(routes.iter().any(|r| r.path == "/first-party/ad"));
assert!(routes.iter().any(|r| r.path == "/third-party/ad"));
assert!(routes.iter().any(|r| r.path == "/ad/render"));
assert!(routes.iter().any(|r| r.path == "/ad/auction"));

// Verify script pattern routes
assert!(routes.iter().any(|r| r.path == "/prebid.js"));
assert!(routes.iter().any(|r| r.path == "/prebid.min.js"));
assert!(routes.iter().any(|r| r.path == "/prebidjs.js"));
assert!(routes.iter().any(|r| r.path == "/prebidjs.min.js"));
}

#[test]
fn config_script_tag_generates_render_mode() {
let tag = config_script_tag(Mode::Render);
assert!(tag.starts_with("<script>"));
assert!(tag.ends_with("</script>"));
assert!(tag.contains(r#"mode:"render""#));
assert!(tag.contains("tsjs.setConfig"));
assert!(tag.contains("tsjs.que.push"));
}

#[test]
fn config_script_tag_generates_auction_mode() {
let tag = config_script_tag(Mode::Auction);
assert!(tag.starts_with("<script>"));
assert!(tag.ends_with("</script>"));
assert!(tag.contains(r#"mode:"auction""#));
assert!(tag.contains("tsjs.setConfig"));
}

#[test]
fn head_injector_returns_empty_when_mode_not_set() {
let integration = PrebidIntegration::new(base_config());
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &Default::default(),
};
let inserts = integration.head_inserts(&ctx);
assert!(
inserts.is_empty(),
"should not inject config when mode is None"
);
}

#[test]
fn head_injector_returns_config_script_when_mode_set() {
let mut config = base_config();
config.mode = Some(Mode::Auction);
let integration = PrebidIntegration::new(config);
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &Default::default(),
};
let inserts = integration.head_inserts(&ctx);
assert_eq!(inserts.len(), 1);
assert!(inserts[0].contains(r#"mode:"auction""#));
}
}
Loading