diff --git a/.github/workflows/aps-real-gam.yml b/.github/workflows/aps-real-gam.yml
new file mode 100644
index 000000000..2b6879e4e
--- /dev/null
+++ b/.github/workflows/aps-real-gam.yml
@@ -0,0 +1,118 @@
+name: "APS real-GAM attestation"
+run-name: >-
+ APS real-GAM / ${{ inputs.evidence_id }} / ${{ inputs.release_id }}
+
+permissions:
+ contents: read
+
+on:
+ workflow_dispatch:
+ inputs:
+ release_id:
+ description: Exact TSJS release id deployed to the protected test network
+ required: true
+ type: string
+ evidence_id:
+ description: Unique cutover evidence identifier
+ required: true
+ type: string
+ previous_artifact_id:
+ description: Immutable artifact identifier used for rollback
+ required: true
+ type: string
+
+jobs:
+ attest:
+ name: Chromium, Firefox, and WebKit attestation
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ environment: aps-real-gam
+ env:
+ TS_REAL_GAM_PAGE_URL: ${{ secrets.TS_REAL_GAM_PAGE_URL }}
+ TS_REAL_GAM_AUTH_HEADER: ${{ secrets.TS_REAL_GAM_AUTH_HEADER }}
+ TS_REAL_GAM_EXPECTED_RELEASE_ID: ${{ vars.TS_REAL_GAM_EXPECTED_RELEASE_ID }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate protected inputs and release binding
+ env:
+ DISPATCH_RELEASE_ID: ${{ inputs.release_id }}
+ run: |
+ test -n "$TS_REAL_GAM_PAGE_URL"
+ test -n "$TS_REAL_GAM_AUTH_HEADER"
+ test -n "$TS_REAL_GAM_EXPECTED_RELEASE_ID"
+ test -n "$DISPATCH_RELEASE_ID"
+ test "$DISPATCH_RELEASE_ID" = "$TS_REAL_GAM_EXPECTED_RELEASE_ID"
+ test -n "${{ inputs.evidence_id }}"
+ test -n "${{ inputs.previous_artifact_id }}"
+
+ - name: Read Node.js version
+ id: node-version
+ run: echo "version=$(awk '$1 == \"nodejs\" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.node-version.outputs.version }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-integration-tests/browser/package-lock.json
+
+ - name: Install isolated browser-test dependencies
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: npm ci
+
+ - name: Install all required browsers
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: npx playwright install --with-deps chromium firefox webkit
+
+ - name: Run protected real-GAM contract
+ id: real-gam
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: >-
+ npm exec -- playwright test
+ --config=playwright.real-gam.config.ts
+ tests/shared/aps-real-gam.spec.ts
+ --project=chromium --project=firefox --project=webkit
+
+ - name: Write release attestation
+ if: always()
+ env:
+ ATTESTATION_EVIDENCE_ID: ${{ inputs.evidence_id }}
+ ATTESTATION_RELEASE_ID: ${{ inputs.release_id }}
+ ATTESTATION_PREVIOUS_ARTIFACT_ID: ${{ inputs.previous_artifact_id }}
+ ATTESTATION_TEST_OUTCOME: ${{ steps.real-gam.outcome }}
+ run: >-
+ node -e 'const fs=require("node:fs");
+ const path="crates/trusted-server-integration-tests/browser/real-gam-evidence/attestation-v1.json";
+ fs.mkdirSync(require("node:path").dirname(path),{recursive:true});
+ fs.writeFileSync(path,JSON.stringify({schemaVersion:1,evidenceId:process.env.ATTESTATION_EVIDENCE_ID,
+ releaseId:process.env.ATTESTATION_RELEASE_ID,previousArtifactId:process.env.ATTESTATION_PREVIOUS_ARTIFACT_ID,
+ commitSha:process.env.GITHUB_SHA,runId:process.env.GITHUB_RUN_ID,
+ conclusion:process.env.ATTESTATION_TEST_OUTCOME},null,2)+"\n",{mode:384});'
+
+ - name: Scrub browser evidence before upload
+ if: always()
+ env:
+ REAL_GAM_TEST_OUTCOME: ${{ steps.real-gam.outcome }}
+ working-directory: crates/trusted-server-integration-tests/browser
+ run: >-
+ node -e 'const fs=require("node:fs"),path=require("node:path");
+ const roots=["real-gam-evidence","playwright-report","test-results"];
+ const forbiddenExt=new Set([".har",".zip",".webm"]), secrets=[process.env.TS_REAL_GAM_PAGE_URL,process.env.TS_REAL_GAM_AUTH_HEADER].filter(Boolean);
+ const files=[]; const walk=p=>{if(!fs.existsSync(p))return; for(const e of fs.readdirSync(p,{withFileTypes:true})){const q=path.join(p,e.name); e.isDirectory()?walk(q):files.push(q)}}; roots.forEach(walk);
+ for(const file of files){if(forbiddenExt.has(path.extname(file)))throw Error("native capture forbidden: "+path.extname(file)); const body=fs.readFileSync(file); for(const secret of secrets){if(body.includes(Buffer.from(secret)))throw Error("protected value found in browser evidence")}}
+ const traces=files.filter(file=>file.endsWith("sanitized-trace-v1.json"));
+ for(const file of traces){const text=fs.readFileSync(file,"utf8"); if(/"(?:accountId|aaxResponse|adm|authorization|creativeBody|descriptor|lifecycleTicket|nonce|postData|requestHeaders|responseBody|responseHeaders)"\s*:/.test(text)||text.includes(""#;
- let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string())));
+ fn injects_exact_boot_projection_before_core_without_legacy_slot_or_bid_surfaces() {
+ let projection = r#"{"version":1,"auction":{"version":1,"auctionId":"auction-1","results":[{"slot":"slot-a","outcome":"no_bid"}]},"bids":[]}"#;
+ let config = HtmlProcessorConfig {
+ origin_host: "origin.example.com".to_owned(),
+ request_host: "example.com".to_owned(),
+ request_scheme: "https".to_owned(),
+ integrations: IntegrationRegistry::default(),
+ ad_slots_script: Some(
+ "".to_owned(),
+ ),
+ ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(Some(projection.to_owned()))),
+ max_buffered_body_bytes: 16 * 1024 * 1024,
+ gpt_diagnostics: None,
+ render_trace_overlay: false,
+ suppress_datadome_client_side_tag: false,
+ };
+ let mut processor = create_html_processor(config);
+ let output = processor
+ .process_chunk(
+ b"
x",
+ true,
+ )
+ .expect("should process HTML");
+ let html = std::str::from_utf8(&output).expect("should produce UTF-8");
+ let boot = html
+ .find("t.boot=")
+ .expect("should emit exact boot transport");
+ let core = html
+ .find("id=\"trustedserver-js\"")
+ .expect("should emit core bundle");
+
+ assert!(boot < core, "boot transport must precede core execution");
+ assert!(html.contains(r#""auctionId":"auction-1""#));
+ assert!(html.contains(
+ r#""creative":{"version":1,"enabled":true,"clickGuard":true,"renderGuard":false}"#
+ ));
+ assert_eq!(html.matches("tsjs:bids-script").count(), 1);
+ assert_eq!(html.matches("t.boot=").count(), 1);
+ assert!(!html.contains(".adSlots"));
+ assert!(!html.contains(".bids="));
+ }
+
+ #[test]
+ fn injects_projection_in_boot_before_core_instead_of_bids_at_body_close() {
+ let projection = r#"{"version":1,"auction":{"version":1,"auctionId":"auction-body","results":[]},"bids":[]}"#;
+ let state = std::sync::Arc::new(std::sync::Mutex::new(Some(projection.to_owned())));
let config = HtmlProcessorConfig {
origin_host: "origin.example.com".to_string(),
request_host: "example.com".to_string(),
@@ -1669,6 +1719,7 @@ mod tests {
ad_bids_state: state,
max_buffered_body_bytes: 16 * 1024 * 1024,
gpt_diagnostics: None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
};
let mut processor = create_html_processor(config);
@@ -1677,24 +1728,21 @@ mod tests {
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
assert!(
- html.contains("window.tsjs=window.tsjs||{}"),
- "should inject _ts namespace for bids before elements (common in CMS template pages)
let output = processor
- .process_chunk(b"content", true)
+ .process_chunk(
+ b"content",
+ true,
+ )
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
assert_eq!(
- html.matches(".bids=JSON.parse").count(),
+ html.matches("t.boot=").count(),
1,
- "should inject tsjs.bids exactly once even with multiple elements"
+ "should inject immutable boot exactly once even with multiple body elements"
);
+ assert!(!html.contains(".bids="));
}
#[test]
@@ -1742,6 +1795,7 @@ mod tests {
ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)),
max_buffered_body_bytes: 16 * 1024 * 1024,
gpt_diagnostics: None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
};
let mut processor = create_html_processor(config);
@@ -1781,9 +1835,7 @@ mod tests {
}
#[test]
- fn injects_empty_ts_bids_when_slots_matched_but_auction_returned_nothing() {
- // Slots matched (ad_slots_script is Some) but auction task never wrote a result
- // (state is None) — e.g. auction timed out with zero bids. Fallback to {}.
+ fn injects_safe_empty_projection_when_auction_returned_nothing() {
let state = std::sync::Arc::new(std::sync::Mutex::new(None));
let config = HtmlProcessorConfig {
origin_host: "origin.example.com".to_string(),
@@ -1796,6 +1848,7 @@ mod tests {
ad_bids_state: state,
max_buffered_body_bytes: 16 * 1024 * 1024,
gpt_diagnostics: None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
};
let mut processor = create_html_processor(config);
@@ -1804,16 +1857,14 @@ mod tests {
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
assert!(
- html.contains("JSON.parse(\"{}\")"),
- "should inject empty bids fallback when auction produced nothing"
+ html.contains(r#""auctionId":"initial","results":[]},"slots":[],"bids":[]"#),
+ "should inject the exact safe empty initial projection"
);
+ assert!(!html.contains(".bids="));
}
#[test]
- fn does_not_inject_ts_bids_when_no_slots_matched() {
- // No slots matched this URL — ad_slots_script is None. tsjs.bids must be
- // omitted entirely so the publisher's existing client-side GPT flow is
- // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified").
+ fn emits_empty_boot_but_no_legacy_bids_when_no_slots_matched() {
let state = std::sync::Arc::new(std::sync::Mutex::new(None));
let config = HtmlProcessorConfig {
origin_host: "origin.example.com".to_string(),
@@ -1824,6 +1875,7 @@ mod tests {
ad_bids_state: state,
max_buffered_body_bytes: 16 * 1024 * 1024,
gpt_diagnostics: None,
+ render_trace_overlay: false,
suppress_datadome_client_side_tag: false,
};
let mut processor = create_html_processor(config);
@@ -1832,9 +1884,10 @@ mod tests {
.expect("should process");
let html = std::str::from_utf8(&output).expect("should be utf8");
assert!(
- !html.contains("JSON.parse"),
- "should NOT inject tsjs.bids when no slots matched"
+ html.contains("t.boot="),
+ "every document should receive one complete boot value"
);
+ assert!(!html.contains(".bids="));
}
#[test]
diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs
index 15ef35842..73a460525 100644
--- a/crates/trusted-server-core/src/integrations/adserver_mock.rs
+++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs
@@ -97,36 +97,29 @@ impl IntegrationConfig for AdServerMockConfig {
/// mediation response to restore render/accounting fields that the mock
/// mediator endpoint does not echo back.
///
-/// Keyed by `(provider_name, slot_id, bidder_name)`.
-type BidIndex = HashMap<(String, String, String), Bid>;
+/// Keyed only by the server-minted opaque candidate identifier.
+type BidIndex = HashMap;
/// Builds the SSP-bid lookup index from the orchestrator-provided
/// bidder responses on the auction context.
-fn build_bid_index(bidder_responses: &[AuctionResponse]) -> BidIndex {
+fn build_bid_index(bidder_responses: &[AuctionResponse]) -> Option {
let mut index = BidIndex::new();
for response in bidder_responses {
for bid in &response.bids {
- let key = (
- response.provider.clone(),
- bid.slot_id.clone(),
- bid.bidder.clone(),
- );
- // OpenRTB permits a seat to return multiple bids per imp. This index
- // is last-write-wins, so a collision means an earlier bid's
- // nurl/burl/cache_* are dropped and win/billing-URL restoration can
- // be mis-attributed during mediation. Low severity for the mock
- // mediator, but log it so the collision is visible.
- if index.insert(key, bid.clone()).is_some() {
+ let Some(candidate_id) = bid.candidate_id.as_ref() else {
log::warn!(
- "adserver_mock: duplicate bid for (provider '{}', slot '{}', bidder '{}'); keeping the last — win/billing URL restoration may be mis-attributed",
- response.provider,
- bid.slot_id,
- bid.bidder
+ "adserver_mock: source bid from provider '{}' lacks a candidate id",
+ response.provider
);
+ return None;
+ };
+ if index.insert(candidate_id.clone(), bid.clone()).is_some() {
+ log::warn!("adserver_mock: duplicate source candidate id");
+ return None;
}
}
}
- index
+ Some(index)
}
/// Mock ad server mediator provider.
@@ -179,14 +172,26 @@ impl AdServerMockProvider {
);
return None;
};
+ let Some(candidate_id) = bid.candidate_id.as_deref() else {
+ log::warn!(
+ "adserver_mock: omitting source bid for slot '{}' without a candidate id",
+ bid.slot_id
+ );
+ return None;
+ };
Some(json!({
"imp_id": bid.slot_id,
"price": price,
"adm": bid.creative,
"w": bid.width,
"h": bid.height,
- "crid": format!("{}-creative", bid.bidder),
+ "crid": bid.creative_id,
"adomain": bid.adomain,
+ "ext": {
+ "trusted_server": {
+ "candidate_id": candidate_id
+ }
+ }
}))
})
.collect();
@@ -254,94 +259,62 @@ impl AdServerMockProvider {
/// Mediation returns decoded prices for all selected bids.
///
/// `bid_index` is the SSP-bid lookup built from the auction context's
- /// bidder responses. The mock mediator does not echo render/accounting
- /// fields back, so they are restored from the index using
- /// `(seat, impid, bidder)` where bidder is recovered from the echoed `crid`
- /// field (`"{bidder}-creative"` format set during request construction).
- /// `bid_id` is the exception: the mediation response carries its own
- /// `OpenRTB` bid `id`, which is preferred over the original SSP bid's.
+ /// bidder responses. The mediator may select only by echoing one exact
+ /// server-minted candidate id; all render and accounting authority is
+ /// restored from the indexed source candidate.
fn parse_mediation_response(
&self,
json: &Json,
response_time_ms: u64,
bid_index: &BidIndex,
) -> AuctionResponse {
- let empty_array = vec![];
- let seatbid = json["seatbid"].as_array().unwrap_or(&empty_array);
-
+ let Some(seatbids) = json.get("seatbid").and_then(Json::as_array) else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
let mut all_bids = Vec::new();
-
- for seat in seatbid {
- let seat_name = seat["seat"].as_str().unwrap_or("unknown");
- let empty_bids = vec![];
- let bids = seat["bid"].as_array().unwrap_or(&empty_bids);
-
+ let mut seen = std::collections::HashSet::new();
+ let mut seen_slots = std::collections::HashSet::new();
+ for seat in seatbids {
+ let Some(bids) = seat.get("bid").and_then(Json::as_array) else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
for bid in bids {
- let slot_id = bid["impid"].as_str().unwrap_or("").to_string();
-
- // Recover bidder name from crid ("{bidder}-creative") to look up the
- // original SSP bid and restore render/accounting fields the mediator drops.
- let crid = bid["crid"].as_str().unwrap_or("");
- let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| {
- log::debug!(
- "adserver_mock: crid '{crid}' does not match '-creative'; render/accounting fields may be missing"
- );
- ""
- });
- let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string());
- let original = bid_index.get(&key);
- let restored_bidder =
- original.map_or_else(|| seat_name.to_string(), |b| b.bidder.clone());
-
- let width = bid["w"].as_u64().unwrap_or(0) as u32;
- let height = bid["h"].as_u64().unwrap_or(0) as u32;
- if width == 0 || height == 0 {
- log::debug!(
- "adserver_mock: bid for slot '{slot_id}' has zero dimension ({width}×{height}), skipping"
- );
- continue;
+ let trusted = bid
+ .get("ext")
+ .and_then(Json::as_object)
+ .and_then(|ext| ext.get("trusted_server"))
+ .and_then(Json::as_object);
+ let candidate_id = trusted
+ .filter(|trusted| trusted.len() == 1)
+ .and_then(|trusted| trusted.get("candidate_id"))
+ .and_then(Json::as_str);
+ let Some(candidate_id) = candidate_id else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
+ let Some(original) = bid_index.get(candidate_id) else {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
+ };
+ if !seen.insert(candidate_id)
+ || !seen_slots.insert(original.slot_id.as_str())
+ || bid.get("impid").and_then(Json::as_str) != Some(original.slot_id.as_str())
+ || bid
+ .get("price")
+ .and_then(Json::as_f64)
+ .is_none_or(|price| !price.is_finite() || price < 0.0)
+ || (original.renderer.is_some()
+ && bid.get("adm").and_then(Json::as_str).is_some())
+ {
+ return AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance"));
}
- all_bids.push(Bid {
- slot_id,
- price: bid["price"].as_f64(),
- currency: "USD".to_string(),
- creative: if original.is_some_and(|bid| bid.renderer.is_some()) {
- None
- } else {
- bid["adm"].as_str().map(String::from)
- },
- width,
- height,
- bidder: restored_bidder,
- adomain: bid["adomain"].as_array().map(|arr| {
- arr.iter()
- .filter_map(|v| v.as_str().map(String::from))
- .collect()
- }),
- nurl: original.and_then(|b| b.nurl.clone()),
- burl: original.and_then(|b| b.burl.clone()),
- // The original SSP bid's id wins: a typed `renderer` envelope is
- // minted against it, and `build_bid_map` derives `hb_adid` from
- // that pairing, so substituting the mediator's own id would key
- // targeting to an id the renderer does not know. The mediated
- // `OpenRTB` bid's `id` is the fallback for a mediator whose
- // upstream bid carried none — without either, a bid whose only
- // `hb_adid` source is the bid id loses it and never renders.
- bid_id: original.and_then(|b| b.bid_id.clone()).or_else(|| {
- bid["id"]
- .as_str()
- .filter(|id| !id.is_empty())
- .map(String::from)
- }),
- ad_id: original.and_then(|bid| bid.ad_id.clone()),
- creative_id: original.and_then(|bid| bid.creative_id.clone()),
- renderer: original.and_then(|bid| bid.renderer.clone()),
- cache_id: original.and_then(|b| b.cache_id.clone()),
- cache_host: original.and_then(|b| b.cache_host.clone()),
- cache_path: original.and_then(|b| b.cache_path.clone()),
- metadata: HashMap::new(),
- });
+ let mut resolved = original.clone();
+ resolved.price = bid.get("price").and_then(Json::as_f64);
+ all_bids.push(resolved);
}
}
@@ -521,7 +494,10 @@ impl AuctionProvider for AdServerMockProvider {
// Rebuild the SSP-bid lookup from the orchestrator-provided bidder
// responses so nurl/burl/ad_id survive mediation. Request-scoped data
// travels on the context instead of provider-instance state.
- let bid_index = build_bid_index(context.provider_responses.unwrap_or(&[]));
+ let Some(bid_index) = build_bid_index(context.provider_responses.unwrap_or(&[])) else {
+ return Ok(AuctionResponse::error("adserver_mock", response_time_ms)
+ .with_metadata("mediation_error", json!("invalid_candidate_provenance")));
+ };
self.parse_response_inner(response, response_time_ms, &bid_index)
.await
}
@@ -635,6 +611,12 @@ mod tests {
fn aps_bid(bid_id: &str, price: f64) -> Bid {
Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some(format!(
+ "{:012x}",
+ bid_id.bytes().map(u64::from).sum::()
+ )),
+ candidate_provider: Some("aps".to_string()),
+ renderer_reservation_id: None,
price: Some(price),
currency: "USD".to_string(),
creative: None,
@@ -647,7 +629,7 @@ mod tests {
bid_id: Some(bid_id.to_string()),
ad_id: None,
creative_id: Some(format!("creative-{bid_id}")),
- renderer: Some(BidRenderer::Aps(ApsRendererV1 {
+ renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: bid_id.to_string(),
@@ -684,6 +666,9 @@ mod tests {
status: BidStatus::Success,
bids: vec![Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some("AAAAAAAAAAAA".to_string()),
+ candidate_provider: Some("aps".to_string()),
+ renderer_reservation_id: None,
price: Some(3.00),
currency: "USD".to_string(),
creative: Some("APS Ad
".to_string()),
@@ -710,6 +695,9 @@ mod tests {
status: BidStatus::Success,
bids: vec![Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some("BBBBBBBBBBBB".to_string()),
+ candidate_provider: Some("test-bidder".to_string()),
+ renderer_reservation_id: None,
price: Some(3.50),
currency: "USD".to_string(),
creative: Some("Test Ad
".to_string()),
@@ -754,12 +742,26 @@ mod tests {
2
);
assert_eq!(mediation_req["ext"]["config"]["price_floor"], 1.00);
+ assert_eq!(
+ mediation_req["ext"]["bidder_responses"][0]["bids"][0]["ext"]["trusted_server"]["candidate_id"],
+ "AAAAAAAAAAAA"
+ );
+ assert_eq!(
+ mediation_req["ext"]["bidder_responses"][1]["bids"][0]["ext"]["trusted_server"]["candidate_id"],
+ "BBBBBBBBBBBB"
+ );
}
#[test]
fn test_parse_mediation_response() {
let config = AdServerMockConfig::default();
let provider = AdServerMockProvider::new(config);
+ let source = aps_bid("selected", 1.0);
+ let candidate_id = source
+ .candidate_id
+ .clone()
+ .expect("source should have candidate id");
+ let bid_index = HashMap::from([(candidate_id.clone(), source)]);
let mediation_response = json!({
"id": "test-auction-123",
@@ -771,11 +773,7 @@ mod tests {
"id": "bid-001",
"impid": "header-banner",
"price": 3.50,
- "adm": "Test Ad
",
- "w": 728,
- "h": 90,
- "crid": "test-creative",
- "adomain": ["test.com"]
+ "ext": {"trusted_server": {"candidate_id": candidate_id}}
}
]
}
@@ -784,7 +782,7 @@ mod tests {
});
let auction_response =
- provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new());
+ provider.parse_mediation_response(&mediation_response, 200, &bid_index);
assert_eq!(auction_response.provider, "adserver_mock");
assert_eq!(auction_response.status, BidStatus::Success);
@@ -794,7 +792,7 @@ mod tests {
let bid = &auction_response.bids[0];
assert_eq!(bid.slot_id, "header-banner");
assert_eq!(bid.price, Some(3.50)); // Mediation returns decoded price
- assert_eq!(bid.bidder, "test-bidder");
+ assert_eq!(bid.bidder, "aps");
assert_eq!(bid.width, 728);
assert_eq!(bid.height, 90);
}
@@ -802,6 +800,7 @@ mod tests {
#[test]
fn parse_mediation_response_restores_original_bid_render_fields() {
let provider = AdServerMockProvider::new(AdServerMockConfig::default());
+ let candidate_id = "AAAAAAAAAAAA";
let mediation_response = json!({
"id": "test-auction-123",
"seatbid": [
@@ -812,11 +811,7 @@ mod tests {
"id": "mediated-bid-001",
"impid": "header-banner",
"price": 0.20,
- "adm": "Mediated Ad
",
- "w": 728,
- "h": 90,
- "crid": "mocktioneer-creative",
- "adomain": ["example.com"]
+ "ext": {"trusted_server": {"candidate_id": candidate_id}}
}
]
}
@@ -825,13 +820,12 @@ mod tests {
});
let mut bid_index = BidIndex::new();
bid_index.insert(
- (
- "prebid".to_string(),
- "header-banner".to_string(),
- "mocktioneer".to_string(),
- ),
+ candidate_id.to_string(),
Bid {
slot_id: "header-banner".to_string(),
+ candidate_id: Some(candidate_id.to_string()),
+ candidate_provider: Some("prebid".to_string()),
+ renderer_reservation_id: None,
price: Some(0.20),
currency: "USD".to_string(),
creative: Some("Original Ad
".to_string()),
@@ -844,7 +838,7 @@ mod tests {
bid_id: Some("source-bid-id".to_string()),
ad_id: Some("bid-impression-id".to_string()),
creative_id: Some("source-creative-id".to_string()),
- renderer: Some(BidRenderer::Aps(ApsRendererV1 {
+ renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: "example-account".to_string(),
bid_id: "source-bid-id".to_string(),
@@ -882,11 +876,7 @@ mod tests {
Some("https://ssp.example/bill"),
"should restore burl"
);
- assert_eq!(
- bid.bid_id.as_deref(),
- Some("source-bid-id"),
- "should keep the original bid id the renderer envelope is keyed on, not the mediator's"
- );
+ assert_eq!(bid.bid_id.as_deref(), Some("source-bid-id"));
assert_eq!(
bid.ad_id.as_deref(),
Some("bid-impression-id"),
@@ -912,113 +902,37 @@ mod tests {
}
#[test]
- fn parse_mediation_response_falls_back_to_mediated_bid_id() {
- // The original bid carries no id of its own, so there is nothing to
- // restore and no renderer envelope to stay consistent with. The mediation
- // response is itself OpenRTB, so its bid `id` is the remaining hb_adid
- // source — without it the bid reaches the page with no hb_adid and the
- // render bridge never receives a matching request.
+ fn candidate_index_preserves_multiple_same_provider_slot_bids() {
let provider = AdServerMockProvider::new(AdServerMockConfig::default());
- let mediation_response = json!({
- "id": "test-auction-123",
- "seatbid": [
- {
- "seat": "prebid",
- "bid": [
- {
- "id": "mediated-bid-002",
- "impid": "header-banner",
- "price": 0.20,
- "adm": "Mediated Ad
",
- "w": 728,
- "h": 90,
- "crid": "example-bidder-creative",
- }
- ]
- }
- ],
- "cur": "USD"
- });
- let mut bid_index = BidIndex::new();
- bid_index.insert(
- (
- "prebid".to_string(),
- "header-banner".to_string(),
- "example-bidder".to_string(),
- ),
- Bid {
- slot_id: "header-banner".to_string(),
- price: Some(0.20),
- currency: "USD".to_string(),
- creative: Some("Original Ad
".to_string()),
- adomain: None,
- bidder: "example-bidder".to_string(),
- width: 728,
- height: 90,
- nurl: None,
- burl: None,
- bid_id: None,
- ad_id: None,
- creative_id: None,
- renderer: None,
- cache_id: None,
- cache_host: None,
- cache_path: None,
- metadata: HashMap::new(),
- },
- );
-
- let auction_response =
- provider.parse_mediation_response(&mediation_response, 42, &bid_index);
-
- let bid = &auction_response.bids[0];
- assert_eq!(
- bid.bid_id.as_deref(),
- Some("mediated-bid-002"),
- "should fall back to the mediated OpenRTB bid id when the original has none"
- );
- }
-
- #[test]
- fn reduced_aps_bid_avoids_mediation_index_renderer_collision() {
- let provider = AdServerMockProvider::new(AdServerMockConfig::default());
-
- // Document why APS must reduce before mediation: the mediator index is
- // intentionally last-write-wins for identical provider/slot/bidder keys.
- let unreduced = AuctionResponse::success(
+ let response = AuctionResponse::success(
"aps",
- vec![aps_bid("selected", 2.0), aps_bid("losing-last", 1.0)],
+ vec![aps_bid("selected", 2.0), aps_bid("losing", 1.0)],
1,
);
- let collision_index = build_bid_index(&[unreduced]);
- let key = (
- "aps".to_string(),
- "header-banner".to_string(),
- "aps".to_string(),
- );
- assert_eq!(
- collision_index
- .get(&key)
- .and_then(|bid| bid.bid_id.as_deref()),
- Some("losing-last"),
- "an unreduced response would restore the last candidate's renderer"
- );
-
- let reduced = AuctionResponse::success("aps", vec![aps_bid("selected", 2.0)], 1);
+ let index = build_bid_index(std::slice::from_ref(&response))
+ .expect("unique candidates should build an exact index");
+ assert_eq!(index.len(), 2);
+ let selected_candidate_id = response.bids[0]
+ .candidate_id
+ .clone()
+ .expect("selected bid should have candidate id");
let mediation_request = provider
.build_mediation_request(
&create_test_auction_request(),
- std::slice::from_ref(&reduced),
+ std::slice::from_ref(&response),
)
- .expect("should build mediation request from reduced APS response");
+ .expect("should build mediation request with candidate provenance");
assert_eq!(
mediation_request["ext"]["bidder_responses"][0]["bids"]
.as_array()
.map(Vec::len),
- Some(1)
+ Some(2)
+ );
+ assert_eq!(
+ mediation_request["ext"]["bidder_responses"][0]["bids"][0]["ext"]["trusted_server"]["candidate_id"],
+ selected_candidate_id
);
- let reduced_index = build_bid_index(&[reduced]);
let mediated = provider.parse_mediation_response(
&json!({
"seatbid": [{
@@ -1026,14 +940,12 @@ mod tests {
"bid": [{
"impid": "header-banner",
"price": 2.0,
- "w": 728,
- "h": 90,
- "crid": "aps-creative"
+ "ext": {"trusted_server": {"candidate_id": selected_candidate_id}}
}]
}]
}),
2,
- &reduced_index,
+ &index,
);
let winner = mediated
.bids
@@ -1071,6 +983,19 @@ mod tests {
assert_eq!(auction_response.bids.len(), 0);
}
+ #[test]
+ fn missing_seatbid_is_mediation_failure_not_no_bid() {
+ let provider = AdServerMockProvider::new(AdServerMockConfig::default());
+
+ let response = provider.parse_mediation_response(&json!({}), 100, &BidIndex::new());
+
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ response.metadata["mediation_error"],
+ "invalid_candidate_provenance"
+ );
+ }
+
#[test]
fn test_mediation_request_handles_decoded_bid_without_creative() {
// Typed-renderer bids retain their decoded price when sent to mediation.
@@ -1109,6 +1034,9 @@ mod tests {
status: BidStatus::Success,
bids: vec![Bid {
slot_id: "slot-1".to_string(),
+ candidate_id: Some("CCCCCCCCCCCC".to_string()),
+ candidate_provider: Some("aps".to_string()),
+ renderer_reservation_id: None,
price: Some(1.75),
currency: "USD".to_string(),
creative: None,
@@ -1143,6 +1071,7 @@ mod tests {
let bid = &bidder_resp["bids"][0];
assert_eq!(bid["imp_id"], "slot-1");
+ assert_eq!(bid["ext"]["trusted_server"]["candidate_id"], "CCCCCCCCCCCC");
assert_eq!(
bid["price"].as_f64(),
@@ -1223,8 +1152,7 @@ mod tests {
}
#[test]
- fn test_parse_mediation_response_with_missing_prices() {
- // A malformed mediator response can still omit a selected bid price.
+ fn malformed_mediator_selection_fails_closed() {
let config = AdServerMockConfig::default();
let provider = AdServerMockProvider::new(config);
@@ -1259,20 +1187,11 @@ mod tests {
let auction_response =
provider.parse_mediation_response(&mediation_response, 200, &BidIndex::new());
- assert_eq!(auction_response.status, BidStatus::Success);
- assert_eq!(auction_response.bids.len(), 2);
-
- // First bid should have decoded price
- let bid1 = &auction_response.bids[0];
- assert_eq!(bid1.slot_id, "header-banner");
- assert_eq!(bid1.price, Some(3.50));
-
- // Second bid should have None price (failed decode)
- let bid2 = &auction_response.bids[1];
- assert_eq!(bid2.slot_id, "sidebar");
+ assert_eq!(auction_response.status, BidStatus::Error);
+ assert!(auction_response.bids.is_empty());
assert_eq!(
- bid2.price, None,
- "Bid without price field should have None price"
+ auction_response.metadata["mediation_error"],
+ "invalid_candidate_provenance"
);
}
diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs
index 519f69a13..c2d390c1b 100644
--- a/crates/trusted-server-core/src/integrations/aps.rs
+++ b/crates/trusted-server-core/src/integrations/aps.rs
@@ -1,6 +1,6 @@
//! Amazon Publisher Services (APS/TAM) `OpenRTB` integration.
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
@@ -18,10 +18,12 @@ use validator::{Validate, ValidationError};
use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome};
use crate::auction::types::{
- AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, AuctionResponse, Bid,
- BidRenderer, MediaType,
+ AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext,
+ AuctionDropReason, AuctionDropReasons, AuctionRequest, AuctionResponse, Bid, BidRenderSourceV1,
+ MediaType, RENDER_DIMENSION_MAX, classify_aps_renderer_v1, record_auction_drop,
};
use crate::error::TrustedServerError;
+use crate::integrations::ensure_integration_backend_with_transport_timeouts;
use crate::integrations::{
IntegrationEndpoint, IntegrationProxy, IntegrationRegistration,
UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded,
@@ -31,107 +33,154 @@ use crate::openrtb::{
Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, ToExt, User,
UserExt, to_openrtb_i32,
};
-use crate::platform::{PlatformHttpRequest, PlatformResponse, RuntimeServices};
+use crate::platform::{
+ PlatformHttpRequest, PlatformResponse, ProxyHeaderEvidenceV1, RawProxyPolicyV1,
+ RawProxyResponseV1, RuntimeServices,
+};
use crate::settings::{IntegrationConfig, Settings};
const APS_INTEGRATION_ID: &str = "aps";
-const APS_RENDERER_ROUTE: &str = "/integrations/aps/renderer";
+pub const APS_RENDERER_V1_ROUTE: &str = "/integrations/aps/renderer/v1";
+pub const APS_RUNNER_ROUTE: &str = "/integrations/aps/runner.js";
+pub const APS_RUNNER_UPSTREAM_URL: &str =
+ "https://client.aps.amazon-adsystem.com/prebid-creative.js";
+pub const APS_RUNNER_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
+pub const APS_RENDERER_SANDBOX: &str = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation";
const DEFAULT_CURRENCY: &str = "USD";
const APS_SDK_SOURCE: &str = "prebid";
const APS_SDK_VERSION: &str = "2.2.0";
const MAX_ACCOUNT_ID_BYTES: usize = 1024;
+const MAX_BID_ID_BYTES: usize = 64;
const MAX_CREATIVE_ID_BYTES: usize = 1024;
const MAX_DEBUG_RESPONSE_PREVIEW_BYTES: usize = 512;
const MAX_CREATIVE_URL_BYTES: usize = 4096;
const MAX_LANGUAGE_BYTES: usize = 8;
const MAX_PAGE_URL_BYTES: usize = 8192;
const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024;
-const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;";
+// Exact transport window from dispatch through the final upstream byte.
+const APS_RUNNER_TOTAL_TIMEOUT: Duration = Duration::from_secs(5);
+/// Maximum wait for the APS runner response headers.
+pub const APS_RUNNER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(4);
+/// Maximum duration of one blocking APS runner response-body read.
+pub const APS_RUNNER_BLOCKING_READ_TIMEOUT: Duration = Duration::from_millis(250);
+/// Whether `path` belongs to the reserved APS integration family.
+#[must_use]
+pub fn is_aps_family_path(path: &str) -> bool {
+ path == "/integrations/aps" || path.starts_with("/integrations/aps/")
+}
+const APS_RENDERER_V1_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:;";
-const APS_RENDERER_DOCUMENT: &str = r#"
+const APS_RENDERER_V1_DOCUMENT: &str = concat!(
+ r#"
-"#;
+"#
+);
/// Configuration for the APS `OpenRTB` integration.
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
@@ -140,8 +189,8 @@ pub struct ApsConfig {
/// Whether APS integration is enabled.
#[serde(default = "default_enabled")]
pub enabled: bool,
- /// APS account ID. `pub_id` remains a deserialization alias only.
- #[serde(alias = "pub_id", deserialize_with = "deserialize_account_id")]
+ /// APS account ID.
+ #[serde(deserialize_with = "deserialize_account_id")]
pub account_id: String,
/// APS `OpenRTB` endpoint.
#[serde(default = "default_endpoint")]
@@ -683,7 +732,7 @@ impl ApsAuctionProvider {
})
}
- fn valid_creative_url(&self, value: &str, publisher_domain: &str) -> bool {
+ fn valid_creative_url(&self, value: &str, publisher_origin: &str) -> bool {
if value.len() > MAX_CREATIVE_URL_BYTES {
return false;
}
@@ -691,14 +740,17 @@ impl ApsAuctionProvider {
return false;
};
parsed.scheme() == "https"
- && parsed
- .host_str()
- .is_some_and(|host| !host.eq_ignore_ascii_case(publisher_domain))
+ && parsed.host_str().is_some()
&& parsed.username().is_empty()
&& parsed.password().is_none()
+ && parsed.origin().ascii_serialization() != publisher_origin
}
- fn build_renderer(&self, input: ApsRendererInput<'_>) -> Option {
+ fn build_renderer(
+ &self,
+ input: ApsRendererInput<'_>,
+ publisher_origin: &str,
+ ) -> Result {
let tag_type_value = match input.tag_type {
ApsTagType::Iframe => "iframe",
ApsTagType::Script => "script",
@@ -717,11 +769,12 @@ impl ApsAuctionProvider {
}]
}]
});
- let serialized = serde_json::to_vec(&envelope).ok()?;
+ let serialized = serde_json::to_vec(&envelope)
+ .map_err(|_| AuctionDropReason::InvalidProviderResponse)?;
if serialized.len() > MAX_RENDER_ENVELOPE_BYTES {
- return None;
+ return Err(AuctionDropReason::RenderPayloadTooLarge);
}
- Some(BidRenderer::Aps(ApsRendererV1 {
+ let renderer = BidRenderSourceV1::Aps(ApsRendererV1 {
version: 1,
account_id: self.config.account_id.clone(),
bid_id: input.bid_id.to_string(),
@@ -731,83 +784,110 @@ impl ApsAuctionProvider {
aax_response: BASE64_STANDARD.encode(serialized),
width: input.width,
height: input.height,
- }))
- }
-
- fn increment_reason(reasons: &mut BTreeMap, reason: &'static str) {
- *reasons.entry(reason.to_string()).or_default() += 1;
+ });
+ let value = serde_json::to_value(&renderer)
+ .map_err(|_| AuctionDropReason::InvalidProviderResponse)?;
+ if classify_aps_renderer_v1(&value, publisher_origin)
+ != ApsRendererValidationResult::Accepted
+ {
+ return Err(AuctionDropReason::InvalidProviderResponse);
+ }
+ Ok(renderer)
}
fn parse_bid(
&self,
value: &Json,
slots: &HashMap<&str, &AdSlot>,
- publisher_domain: &str,
- ) -> Result {
- let bid_id = value
- .get("id")
- .and_then(Json::as_str)
- .filter(|value| !value.is_empty())
- .ok_or("missing_render_source")?;
+ publisher_origin: &str,
+ duplicate_ids: &HashSet,
+ ) -> Result {
+ let object = value.as_object().ok_or(AuctionDropReason::MalformedBid)?;
+ let Some(bid_id_value) = object.get("id") else {
+ return Err(AuctionDropReason::MissingUpstreamBidId);
+ };
+ let bid_id = bid_id_value
+ .as_str()
+ .ok_or(AuctionDropReason::InvalidUpstreamBidId)?;
+ if bid_id.is_empty() {
+ return Err(AuctionDropReason::MissingUpstreamBidId);
+ }
+ if bid_id.len() > MAX_BID_ID_BYTES {
+ return Err(AuctionDropReason::UpstreamBidIdTooLarge);
+ }
+ if bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) {
+ return Err(AuctionDropReason::InvalidUpstreamBidId);
+ }
+ if duplicate_ids.contains(bid_id) {
+ return Err(AuctionDropReason::DuplicateUpstreamBidId);
+ }
let slot_id = value
.get("impid")
.and_then(Json::as_str)
- .ok_or("unknown_impid")?;
- let slot = slots.get(slot_id).ok_or("unknown_impid")?;
+ .ok_or(AuctionDropReason::UnknownImpression)?;
+ let slot = slots
+ .get(slot_id)
+ .ok_or(AuctionDropReason::UnknownImpression)?;
let price = value
.get("price")
.and_then(Json::as_f64)
.filter(|price| price.is_finite() && *price >= 0.0)
- .ok_or("invalid_price")?;
+ .ok_or(AuctionDropReason::InvalidPrice)?;
if value
.get("mtype")
.is_some_and(|mtype| mtype.as_i64() != Some(1))
{
- return Err("unsupported_media_type");
- }
- let width = value
- .get("w")
- .and_then(Json::as_u64)
- .and_then(|value| u32::try_from(value).ok())
- .ok_or("invalid_dimensions")?;
- let height = value
- .get("h")
- .and_then(Json::as_u64)
- .and_then(|value| u32::try_from(value).ok())
- .ok_or("invalid_dimensions")?;
+ return Err(AuctionDropReason::UnsupportedMediaType);
+ }
+ let parse_dimension = |field: &str| {
+ let number = value
+ .get(field)
+ .and_then(Json::as_f64)
+ .filter(|number| number.is_finite() && number.fract() == 0.0 && *number > 0.0)
+ .ok_or(AuctionDropReason::InvalidDimensions)?;
+ if number > RENDER_DIMENSION_MAX as f64 {
+ return Err(AuctionDropReason::DimensionsOutOfRange);
+ }
+ u32::try_from(number as u64).map_err(|_| AuctionDropReason::DimensionsOutOfRange)
+ };
+ let width = parse_dimension("w")?;
+ let height = parse_dimension("h")?;
if !Self::compatible_dimensions(slot, width, height) {
- return Err("invalid_dimensions");
+ return Err(AuctionDropReason::InvalidDimensions);
}
let ext = value
.get("ext")
.and_then(Json::as_object)
- .ok_or("missing_render_source")?;
- let creative_url = ext
- .get("creativeurl")
- .and_then(Json::as_str)
- .ok_or("missing_render_source")?;
- if !self.valid_creative_url(creative_url, publisher_domain) {
- return Err("invalid_creative_url");
+ .ok_or(AuctionDropReason::MissingCreativeUrl)?;
+ let Some(creative_url_value) = ext.get("creativeurl") else {
+ return Err(AuctionDropReason::MissingCreativeUrl);
+ };
+ let creative_url = creative_url_value
+ .as_str()
+ .ok_or(AuctionDropReason::InvalidCreativeUrl)?;
+ if !self.valid_creative_url(creative_url, publisher_origin) {
+ return Err(AuctionDropReason::InvalidCreativeUrl);
}
let tag_type = match ext.get("tagtype").and_then(Json::as_str) {
Some("iframe") => ApsTagType::Iframe,
Some("script") if self.config.allow_script_creatives => ApsTagType::Script,
- Some("script") => return Err("script_rendering_disabled"),
- _ => return Err("unsupported_tagtype"),
+ Some("script") => return Err(AuctionDropReason::ScriptRenderingDisabled),
+ _ => return Err(AuctionDropReason::InvalidTagType),
+ };
+ let creative_id = match value.get("crid") {
+ None => None,
+ Some(Json::String(creative_id)) if creative_id.is_empty() => None,
+ Some(Json::String(creative_id)) => Some(creative_id.clone()),
+ Some(_) => return Err(AuctionDropReason::InvalidCreativeId),
};
- let creative_id = value
- .get("crid")
- .and_then(Json::as_str)
- .filter(|creative_id| !creative_id.is_empty())
- .map(str::to_string);
if creative_id
.as_ref()
.is_some_and(|creative_id| creative_id.len() > MAX_CREATIVE_ID_BYTES)
{
- return Err("creative_id_too_large");
+ return Err(AuctionDropReason::CreativeIdTooLarge);
}
- let renderer = self
- .build_renderer(ApsRendererInput {
+ let renderer = self.build_renderer(
+ ApsRendererInput {
bid_id,
creative_id: creative_id.clone(),
tag_type,
@@ -815,8 +895,9 @@ impl ApsAuctionProvider {
price,
width,
height,
- })
- .ok_or("render_payload_too_large")?;
+ },
+ publisher_origin,
+ )?;
let adomain = value
.get("adomain")
.and_then(Json::as_array)
@@ -830,6 +911,9 @@ impl ApsAuctionProvider {
Ok(Bid {
slot_id: slot_id.to_string(),
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(price),
currency: DEFAULT_CURRENCY.to_string(),
creative: None,
@@ -866,15 +950,15 @@ impl ApsAuctionProvider {
.is_some_and(|seatbids| !seatbids.is_array())
{
return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1}));
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
}
if value
.get("cur")
.and_then(Json::as_str)
.is_some_and(|currency| !currency.eq_ignore_ascii_case(DEFAULT_CURRENCY))
{
- return AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"unsupported_currency": 1}));
+ return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
}
let slots: HashMap<&str, &AdSlot> = request
@@ -884,18 +968,47 @@ impl ApsAuctionProvider {
.collect();
let seatbids = value.get("seatbid").and_then(Json::as_array);
let seatbid_count = seatbids.map_or(0, Vec::len);
- let mut reasons = BTreeMap::new();
+ let mut reasons = AuctionDropReasons::new();
let mut selected: HashMap = HashMap::new();
let mut dropped = 0_u64;
+ let mut id_counts = HashMap::<&str, usize>::new();
+ for candidate in seatbids
+ .into_iter()
+ .flatten()
+ .filter_map(|seatbid| seatbid.get("bid").and_then(Json::as_array))
+ .flatten()
+ {
+ if let Some(bid_id) = candidate.get("id").and_then(Json::as_str)
+ && !bid_id.is_empty()
+ && bid_id.len() <= MAX_BID_ID_BYTES
+ && !bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f)
+ {
+ *id_counts.entry(bid_id).or_default() += 1;
+ }
+ }
+ let duplicate_ids: HashSet = id_counts
+ .into_iter()
+ .filter(|(_, count)| *count > 1)
+ .map(|(bid_id, _)| bid_id.to_string())
+ .collect();
+
+ let publisher_origin = request
+ .publisher
+ .page_url
+ .as_deref()
+ .and_then(|page_url| Url::parse(page_url).ok())
+ .map(|page_url| page_url.origin().ascii_serialization())
+ .unwrap_or_else(|| format!("https://{}", request.publisher.domain));
+
for seatbid in seatbids.into_iter().flatten() {
let Some(bids) = seatbid.get("bid").and_then(Json::as_array) else {
dropped += 1;
- Self::increment_reason(&mut reasons, "empty_seatbid_bids");
+ record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBidBids);
continue;
};
for value in bids {
- match self.parse_bid(value, &slots, &request.publisher.domain) {
+ match self.parse_bid(value, &slots, &publisher_origin, &duplicate_ids) {
Ok(candidate) => {
let replace = selected.get(&candidate.slot_id).is_none_or(|current| {
let candidate_price = candidate.price.unwrap_or_default();
@@ -911,42 +1024,45 @@ impl ApsAuctionProvider {
.is_some()
{
dropped += 1;
- Self::increment_reason(&mut reasons, "lost_to_higher_bid");
+ record_auction_drop(
+ &mut reasons,
+ AuctionDropReason::LostToHigherBid,
+ );
}
} else {
dropped += 1;
- Self::increment_reason(&mut reasons, "lost_to_higher_bid");
+ record_auction_drop(&mut reasons, AuctionDropReason::LostToHigherBid);
}
}
Err(reason) => {
dropped += 1;
- Self::increment_reason(&mut reasons, reason);
+ record_auction_drop(&mut reasons, reason);
}
}
}
}
if seatbid_count == 0 {
- Self::increment_reason(&mut reasons, "empty_seatbid");
+ record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBid);
}
let accepted = selected.len();
let metadata = [
("seatbid_count".to_string(), json!(seatbid_count)),
("accepted_bid_count".to_string(), json!(accepted)),
("dropped_bid_count".to_string(), json!(dropped)),
- ("drop_reasons".to_string(), json!(reasons)),
];
let mut response = if selected.is_empty() {
AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms)
} else {
- AuctionResponse::success(
- APS_INTEGRATION_ID,
- selected.into_values().collect(),
- response_time_ms,
- )
+ let bids = request
+ .slots
+ .iter()
+ .filter_map(|slot| selected.remove(&slot.id))
+ .collect();
+ AuctionResponse::success(APS_INTEGRATION_ID, bids, response_time_ms)
};
response.metadata.extend(metadata);
- response
+ response.with_drop_reasons(&reasons)
}
async fn parse_response_inner(
@@ -1018,7 +1134,7 @@ impl ApsAuctionProvider {
Err(error) => {
log::warn!("Failed to parse APS response JSON: {error}");
let parsed = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1}));
+ .with_drop_reason(AuctionDropReason::InvalidProviderResponse);
return Ok(self.attach_debug_metadata(
parsed,
debug_enabled,
@@ -1034,7 +1150,7 @@ impl ApsAuctionProvider {
"APS cannot parse a successful bid response without the original auction request context"
);
let response = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms)
- .with_metadata("drop_reasons", json!({"missing_request_context": 1}));
+ .with_drop_reason(AuctionDropReason::MissingRequestContext);
return Ok(self.attach_debug_metadata(
response,
debug_enabled,
@@ -1199,44 +1315,273 @@ impl AuctionProvider for ApsAuctionProvider {
}
#[derive(Debug)]
-struct ApsRendererIntegration;
+pub(crate) struct ApsV1Integration {
+ enabled: bool,
+}
-#[async_trait(?Send)]
-impl IntegrationProxy for ApsRendererIntegration {
- fn integration_name(&self) -> &'static str {
- APS_INTEGRATION_ID
+impl ApsV1Integration {
+ fn mark_exact_headers(mut response: http::Response) -> http::Response {
+ response
+ .extensions_mut()
+ .insert(crate::platform::ExactResponseHeadersV1);
+ response
}
- fn routes(&self) -> Vec {
- vec![IntegrationEndpoint::get(APS_RENDERER_ROUTE)]
+ pub(crate) fn from_settings(settings: &Settings) -> Result> {
+ Ok(Self {
+ enabled: settings
+ .integration_config::(APS_INTEGRATION_ID)?
+ .is_some(),
+ })
}
- async fn handle(
- &self,
- _settings: &Settings,
- _services: &RuntimeServices,
- request: http::Request,
+ fn local_status(
+ status: StatusCode,
+ allow_get: bool,
) -> Result, Report> {
- if request.method() != Method::GET || request.uri().path() != APS_RENDERER_ROUTE {
- return http::Response::builder()
- .status(StatusCode::NOT_FOUND)
- .body(EdgeBody::from("Not Found"))
- .change_context(TrustedServerError::Integration {
- integration: APS_INTEGRATION_ID.to_string(),
- message: "Failed to build APS not-found response".to_string(),
- });
+ let mut builder = http::Response::builder()
+ .status(status)
+ .header(header::CACHE_CONTROL, "no-store");
+ if allow_get {
+ builder = builder.header(header::ALLOW, "GET");
}
+ builder
+ .body(EdgeBody::empty())
+ .change_context(TrustedServerError::Integration {
+ integration: APS_INTEGRATION_ID.to_string(),
+ message: "Failed to build local APS route response".to_string(),
+ })
+ .map(Self::mark_exact_headers)
+ }
+
+ fn renderer_response() -> Result, Report> {
http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+ .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("x-content-type-options", "nosniff")
.header("referrer-policy", "no-referrer")
- .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_CSP)
- .body(EdgeBody::from(APS_RENDERER_DOCUMENT))
+ .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_V1_CSP)
+ .body(EdgeBody::from(APS_RENDERER_V1_DOCUMENT))
.change_context(TrustedServerError::Integration {
integration: APS_INTEGRATION_ID.to_string(),
- message: "Failed to build APS renderer response".to_string(),
+ message: "Failed to build APS renderer v1 response".to_string(),
})
+ .map(Self::mark_exact_headers)
+ }
+
+ fn singleton_proxy_header(
+ evidence: &ProxyHeaderEvidenceV1,
+ required: bool,
+ ) -> Result, &'static str> {
+ match evidence {
+ ProxyHeaderEvidenceV1::Occurrences(values) => match values.as_slice() {
+ [] if !required => Ok(None),
+ [value] => Ok(Some(value.as_slice())),
+ [] => Err("missing_header"),
+ _ => Err("duplicate_header"),
+ },
+ ProxyHeaderEvidenceV1::Combined(value) if !value.contains(&b',') => {
+ Ok(Some(value.as_slice()))
+ }
+ ProxyHeaderEvidenceV1::Combined(_) => Err("listed_header"),
+ ProxyHeaderEvidenceV1::Unavailable => Err("unavailable_header"),
+ }
+ }
+
+ fn trim_http_ows(value: &[u8]) -> &[u8] {
+ let start = value
+ .iter()
+ .position(|byte| !matches!(byte, b' ' | b'\t'))
+ .unwrap_or(value.len());
+ let end = value
+ .iter()
+ .rposition(|byte| !matches!(byte, b' ' | b'\t'))
+ .map_or(start, |index| index + 1);
+ &value[start..end]
+ }
+
+ fn validate_runner_content_type(evidence: &ProxyHeaderEvidenceV1) -> Result<(), &'static str> {
+ let raw = Self::singleton_proxy_header(evidence, true)?;
+ let value = std::str::from_utf8(raw.ok_or("missing_content_type")?)
+ .map_err(|_| "invalid_content_type")?;
+ if !value.is_ascii() {
+ return Err("invalid_content_type");
+ }
+ if value.as_bytes().contains(&b',') {
+ return Err("listed_content_type");
+ }
+
+ let mut parts = value.split(';');
+ let essence = parts
+ .next()
+ .ok_or("missing_content_type")?
+ .trim_matches([' ', '\t']);
+ if !essence.eq_ignore_ascii_case("application/javascript")
+ && !essence.eq_ignore_ascii_case("text/javascript")
+ {
+ return Err("rejected_content_type");
+ }
+ let Some(parameter) = parts.next() else {
+ return Ok(());
+ };
+ if parts.next().is_some() {
+ return Err("duplicate_content_type_parameter");
+ }
+ let (name, value) = parameter
+ .split_once('=')
+ .ok_or("invalid_content_type_parameter")?;
+ if !name
+ .trim_matches([' ', '\t'])
+ .eq_ignore_ascii_case("charset")
+ || !value
+ .trim_matches([' ', '\t'])
+ .eq_ignore_ascii_case("utf-8")
+ {
+ return Err("rejected_content_type_parameter");
+ }
+ Ok(())
+ }
+
+ fn validate_runner_content_encoding(
+ evidence: &ProxyHeaderEvidenceV1,
+ ) -> Result<(), &'static str> {
+ let Some(raw) = Self::singleton_proxy_header(evidence, false)? else {
+ return Ok(());
+ };
+ let value = Self::trim_http_ows(raw);
+ if value.contains(&b',') || !value.eq_ignore_ascii_case(b"identity") {
+ return Err("rejected_content_encoding");
+ }
+ Ok(())
+ }
+
+ fn validate_runner_content_length(
+ evidence: &ProxyHeaderEvidenceV1,
+ ) -> Result , &'static str> {
+ let Some(value) = Self::singleton_proxy_header(evidence, false)? else {
+ return Ok(None);
+ };
+ if value.is_empty()
+ || value.contains(&b',')
+ || !value.iter().all(u8::is_ascii_digit)
+ || (value.len() > 1 && value[0] == b'0')
+ {
+ return Err("invalid_content_length");
+ }
+ let value = std::str::from_utf8(value)
+ .map_err(|_| "invalid_content_length")?
+ .parse::()
+ .map_err(|_| "invalid_content_length")?;
+ if value > APS_RUNNER_MAX_RESPONSE_BYTES {
+ return Err("content_length_overflow");
+ }
+ Ok(Some(value))
+ }
+
+ fn validate_runner_response(response: &RawProxyResponseV1) -> Result<(), &'static str> {
+ if response.evidence.status != StatusCode::OK.as_u16() {
+ return Err("rejected_status");
+ }
+ Self::validate_runner_content_type(&response.evidence.content_type)?;
+ Self::validate_runner_content_encoding(&response.evidence.content_encoding)?;
+ let declared_length =
+ Self::validate_runner_content_length(&response.evidence.content_length)?;
+ if response.body.len() > APS_RUNNER_MAX_RESPONSE_BYTES {
+ return Err("body_overflow");
+ }
+ if declared_length.is_some_and(|length| length != response.body.len()) {
+ return Err("content_length_mismatch");
+ }
+ std::str::from_utf8(&response.body).map_err(|_| "invalid_utf8")?;
+ Ok(())
+ }
+
+ async fn runner_response(
+ services: &RuntimeServices,
+ ) -> Result, &'static str> {
+ let outbound_request = http::Request::builder()
+ .method(Method::GET)
+ .uri(APS_RUNNER_UPSTREAM_URL)
+ .header(header::ACCEPT_ENCODING, "identity")
+ .body(EdgeBody::empty())
+ .map_err(|_| "request_build_failed")?;
+ let backend = ensure_integration_backend_with_transport_timeouts(
+ services,
+ APS_RUNNER_UPSTREAM_URL,
+ APS_INTEGRATION_ID,
+ APS_RUNNER_FIRST_BYTE_TIMEOUT,
+ APS_RUNNER_BLOCKING_READ_TIMEOUT,
+ )
+ .map_err(|_| "backend_unavailable")?;
+ let response = services
+ .http_client()
+ .send_raw_proxy_v1(
+ PlatformHttpRequest::new(outbound_request, backend),
+ RawProxyPolicyV1 {
+ total_timeout: APS_RUNNER_TOTAL_TIMEOUT,
+ first_byte_timeout: APS_RUNNER_FIRST_BYTE_TIMEOUT,
+ blocking_read_timeout: APS_RUNNER_BLOCKING_READ_TIMEOUT,
+ max_response_bytes: APS_RUNNER_MAX_RESPONSE_BYTES,
+ },
+ )
+ .await
+ .map_err(|_| "transport_failed")?;
+ Self::validate_runner_response(&response)?;
+
+ http::Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ )
+ .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
+ .header("cross-origin-resource-policy", "cross-origin")
+ .header("x-content-type-options", "nosniff")
+ .header("referrer-policy", "no-referrer")
+ .body(EdgeBody::from(response.body))
+ .map(Self::mark_exact_headers)
+ .map_err(|_| "response_build_failed")
+ }
+}
+
+#[async_trait(?Send)]
+impl IntegrationProxy for ApsV1Integration {
+ fn integration_name(&self) -> &'static str {
+ APS_INTEGRATION_ID
+ }
+
+ fn routes(&self) -> Vec {
+ Vec::new()
+ }
+
+ async fn handle(
+ &self,
+ _settings: &Settings,
+ services: &RuntimeServices,
+ request: http::Request,
+ ) -> Result, Report> {
+ let path = request.uri().path();
+ if !path.starts_with("/integrations/aps/") {
+ return Self::local_status(StatusCode::NOT_FOUND, false);
+ }
+ if request.method() != Method::GET {
+ return Self::local_status(StatusCode::METHOD_NOT_ALLOWED, true);
+ }
+ if !self.enabled {
+ return Self::local_status(StatusCode::NOT_FOUND, false);
+ }
+ match path {
+ APS_RENDERER_V1_ROUTE => Self::renderer_response(),
+ APS_RUNNER_ROUTE => match Self::runner_response(services).await {
+ Ok(response) => Ok(response),
+ Err(reason) => {
+ log::warn!("APS runner proxy failed closed: {reason}");
+ Self::local_status(StatusCode::BAD_GATEWAY, false)
+ }
+ },
+ _ => Self::local_status(StatusCode::NOT_FOUND, false),
+ }
}
}
@@ -1251,10 +1596,8 @@ pub fn register(
let Some(_config) = settings.integration_config::(APS_INTEGRATION_ID)? else {
return Ok(None);
};
- let integration = Arc::new(ApsRendererIntegration);
Ok(Some(
IntegrationRegistration::builder(APS_INTEGRATION_ID)
- .with_proxy(integration)
.without_js()
.build(),
))
@@ -1284,15 +1627,18 @@ pub fn register_providers(
mod tests {
use super::*;
use crate::auction::types::{
- AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo,
- UserInfo,
+ AdFormat, AdSlot, AuctionContext, AuctionDropReason, AuctionRequest, BidStatus, DeviceInfo,
+ PublisherInfo, UserInfo,
};
use crate::consent::ConsentContext;
use crate::openrtb::{Eid, Uid};
- use crate::platform::GeoInfo;
use crate::platform::test_support::{
StubHttpClient, build_services_with_http_client, noop_services,
};
+ use crate::platform::{
+ GeoInfo, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1,
+ RawProxyResponseV1,
+ };
use crate::test_support::tests::create_test_settings;
use serde_json::json;
@@ -1357,6 +1703,386 @@ mod tests {
})
}
+ fn drop_count(response: &AuctionResponse, reason: AuctionDropReason) -> u64 {
+ response.metadata["drop_reasons"][reason.as_str()]
+ .as_u64()
+ .unwrap_or_default()
+ }
+
+ #[derive(serde::Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ struct RendererCorpus {
+ publisher_origin: String,
+ base_descriptor: Json,
+ vectors: Vec,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct RendererCorpusVector {
+ id: String,
+ expected: String,
+ operation: Json,
+ }
+
+ fn corpus_value<'a>(operation: &'a Json, field: &str) -> &'a Json {
+ operation
+ .get(field)
+ .unwrap_or_else(|| panic!("should include corpus operation field {field}"))
+ }
+
+ fn corpus_string<'a>(operation: &'a Json, field: &str) -> &'a str {
+ corpus_value(operation, field)
+ .as_str()
+ .unwrap_or_else(|| panic!("corpus operation field {field} should be a string"))
+ }
+
+ fn corpus_usize(operation: &Json, field: &str) -> usize {
+ corpus_value(operation, field)
+ .as_u64()
+ .and_then(|value| usize::try_from(value).ok())
+ .unwrap_or_else(|| panic!("corpus operation field {field} should be a usize"))
+ }
+
+ fn set_json_path(root: &mut Json, path: &[Json], value: Json) {
+ let (segment, tail) = path
+ .split_first()
+ .expect("corpus JSON path should not be empty");
+ if tail.is_empty() {
+ if let Some(field) = segment.as_str() {
+ root.as_object_mut()
+ .expect("corpus string path should address an object")
+ .insert(field.to_string(), value);
+ } else {
+ let index = segment
+ .as_u64()
+ .and_then(|index| usize::try_from(index).ok())
+ .expect("corpus numeric path should be a usize");
+ let slot = root
+ .as_array_mut()
+ .and_then(|array| array.get_mut(index))
+ .expect("corpus numeric path should address an array element");
+ *slot = value;
+ }
+ return;
+ }
+
+ let child = if let Some(field) = segment.as_str() {
+ root.as_object_mut()
+ .and_then(|object| object.get_mut(field))
+ .expect("corpus string path should address an object field")
+ } else {
+ let index = segment
+ .as_u64()
+ .and_then(|index| usize::try_from(index).ok())
+ .expect("corpus numeric path should be a usize");
+ root.as_array_mut()
+ .and_then(|array| array.get_mut(index))
+ .expect("corpus numeric path should address an array element")
+ };
+ set_json_path(child, tail, value);
+ }
+
+ fn delete_json_path(root: &mut Json, path: &[Json]) {
+ let (segment, tail) = path
+ .split_first()
+ .expect("corpus JSON path should not be empty");
+ if tail.is_empty() {
+ let field = segment
+ .as_str()
+ .expect("corpus delete path should end in an object field");
+ root.as_object_mut()
+ .expect("corpus delete path should address an object")
+ .remove(field);
+ return;
+ }
+
+ let child = if let Some(field) = segment.as_str() {
+ root.as_object_mut()
+ .and_then(|object| object.get_mut(field))
+ .expect("corpus string path should address an object field")
+ } else {
+ let index = segment
+ .as_u64()
+ .and_then(|index| usize::try_from(index).ok())
+ .expect("corpus numeric path should be a usize");
+ root.as_array_mut()
+ .and_then(|array| array.get_mut(index))
+ .expect("corpus numeric path should address an array element")
+ };
+ delete_json_path(child, tail);
+ }
+
+ fn descriptor_field(descriptor: &mut Json, field: &str, value: Json) {
+ descriptor
+ .as_object_mut()
+ .expect("corpus descriptor should be an object")
+ .insert(field.to_string(), value);
+ }
+
+ fn materialize_renderer_corpus_vector(
+ corpus: &RendererCorpus,
+ vector: &RendererCorpusVector,
+ ) -> Json {
+ let mut descriptor = corpus.base_descriptor.clone();
+ let mut envelope: Json = serde_json::from_str(include_str!(
+ "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json"
+ ))
+ .expect("should parse shared APS renderer fixture");
+ let operation = &vector.operation;
+ let kind = corpus_string(operation, "kind");
+ let mut encoded_envelope = None;
+
+ match kind {
+ "none" => {}
+ "descriptor-delete" => {
+ descriptor
+ .as_object_mut()
+ .expect("corpus descriptor should be an object")
+ .remove(corpus_string(operation, "field"));
+ }
+ "descriptor-set" => descriptor_field(
+ &mut descriptor,
+ corpus_string(operation, "field"),
+ corpus_value(operation, "value").clone(),
+ ),
+ "descriptor-repeat" => {
+ let mut repeated =
+ corpus_string(operation, "unit").repeat(corpus_usize(operation, "count"));
+ if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) {
+ repeated.push_str(suffix);
+ }
+ descriptor_field(
+ &mut descriptor,
+ corpus_string(operation, "field"),
+ json!(repeated),
+ );
+ }
+ "bid-id-repeat" => {
+ let mut repeated =
+ corpus_string(operation, "unit").repeat(corpus_usize(operation, "count"));
+ if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) {
+ repeated.push_str(suffix);
+ }
+ descriptor_field(&mut descriptor, "bidId", json!(repeated));
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("id"),
+ ],
+ json!(repeated),
+ );
+ }
+ "dimension" => {
+ let field = corpus_string(operation, "field");
+ let envelope_field = match field {
+ "width" => "w",
+ "height" => "h",
+ _ => panic!("corpus dimension field should be width or height"),
+ };
+ let value = corpus_value(operation, "value").clone();
+ descriptor_field(&mut descriptor, field, value.clone());
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!(envelope_field),
+ ],
+ value,
+ );
+ }
+ "dimensions" => {
+ let width = corpus_value(operation, "width").clone();
+ let height = corpus_value(operation, "height").clone();
+ descriptor_field(&mut descriptor, "width", width.clone());
+ descriptor_field(&mut descriptor, "height", height.clone());
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("w"),
+ ],
+ width,
+ );
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("h"),
+ ],
+ height,
+ );
+ }
+ "creative-url" => {
+ let value = corpus_string(operation, "value").to_string();
+ descriptor_field(&mut descriptor, "creativeUrl", json!(value));
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("ext"),
+ json!("creativeurl"),
+ ],
+ json!(value),
+ );
+ }
+ "creative-url-bytes" => {
+ let prefix = "https://creative.example/";
+ let bytes = corpus_usize(operation, "bytes");
+ let value = format!(
+ "{prefix}{}",
+ "a".repeat(
+ bytes
+ .checked_sub(prefix.len())
+ .expect("corpus URL size should include its prefix")
+ )
+ );
+ descriptor_field(&mut descriptor, "creativeUrl", json!(value));
+ set_json_path(
+ &mut envelope,
+ &[
+ json!("seatbid"),
+ json!(0),
+ json!("bid"),
+ json!(0),
+ json!("ext"),
+ json!("creativeurl"),
+ ],
+ json!(value),
+ );
+ }
+ "aax-literal" => {
+ encoded_envelope = Some(corpus_string(operation, "value").to_string());
+ }
+ "aax-bytes" => {
+ let bytes: Vec = corpus_value(operation, "values")
+ .as_array()
+ .expect("corpus byte vector should be an array")
+ .iter()
+ .map(|value| {
+ value
+ .as_u64()
+ .and_then(|value| u8::try_from(value).ok())
+ .expect("corpus byte vector should contain u8 values")
+ })
+ .collect();
+ encoded_envelope = Some(BASE64_STANDARD.encode(bytes));
+ }
+ "aax-raw-json" => {
+ encoded_envelope =
+ Some(BASE64_STANDARD.encode(corpus_string(operation, "value").as_bytes()));
+ }
+ "aax-decoded-bytes" => {
+ let mut serialized =
+ serde_json::to_string(&envelope).expect("should serialize corpus envelope");
+ let target = corpus_usize(operation, "bytes");
+ serialized.push_str(
+ &" ".repeat(
+ target
+ .checked_sub(serialized.len())
+ .expect("corpus decoded size should exceed fixture size"),
+ ),
+ );
+ encoded_envelope = Some(BASE64_STANDARD.encode(serialized.as_bytes()));
+ }
+ "aax-raw-price" => {
+ let serialized =
+ serde_json::to_string(&envelope).expect("should serialize corpus envelope");
+ let replacement = format!("\"price\":{}", corpus_string(operation, "value"));
+ let raw = serialized.replacen("\"price\":1.23", &replacement, 1);
+ assert_ne!(raw, serialized, "should replace the corpus fixture price");
+ encoded_envelope = Some(BASE64_STANDARD.encode(raw.as_bytes()));
+ }
+ "envelope-set" => {
+ let path = corpus_value(operation, "path")
+ .as_array()
+ .expect("corpus path should be an array");
+ set_json_path(
+ &mut envelope,
+ path,
+ corpus_value(operation, "value").clone(),
+ );
+ }
+ "envelope-delete" => {
+ let path = corpus_value(operation, "path")
+ .as_array()
+ .expect("corpus path should be an array");
+ delete_json_path(&mut envelope, path);
+ }
+ "duplicate-seat" => {
+ let seats = envelope
+ .get_mut("seatbid")
+ .and_then(Json::as_array_mut)
+ .expect("corpus fixture should contain a seat array");
+ let first = seats
+ .first()
+ .cloned()
+ .expect("corpus fixture should contain one seat");
+ seats.push(first);
+ }
+ "duplicate-bid" => {
+ let bids = envelope
+ .get_mut("seatbid")
+ .and_then(Json::as_array_mut)
+ .and_then(|seats| seats.first_mut())
+ .and_then(|seat| seat.get_mut("bid"))
+ .and_then(Json::as_array_mut)
+ .expect("corpus fixture should contain a bid array");
+ let first = bids
+ .first()
+ .cloned()
+ .expect("corpus fixture should contain one bid");
+ bids.push(first);
+ }
+ _ => panic!("unknown APS renderer corpus operation: {kind}"),
+ }
+
+ let encoded = encoded_envelope.unwrap_or_else(|| {
+ BASE64_STANDARD.encode(
+ serde_json::to_vec(&envelope).expect("should serialize corpus renderer envelope"),
+ )
+ });
+ descriptor_field(&mut descriptor, "aaxResponse", json!(encoded));
+ descriptor
+ }
+
+ #[test]
+ fn aps_renderer_matches_shared_cross_language_contract_corpus() {
+ let corpus: RendererCorpus = serde_json::from_str(include_str!(
+ "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json"
+ ))
+ .expect("should parse shared APS renderer corpus");
+ assert_eq!(
+ corpus.publisher_origin, "https://publisher.example",
+ "should pin the corpus publisher origin"
+ );
+
+ for vector in &corpus.vectors {
+ let descriptor = materialize_renderer_corpus_vector(&corpus, vector);
+ let actual = classify_aps_renderer_v1(&descriptor, &corpus.publisher_origin).as_str();
+ assert_eq!(
+ actual, vector.expected,
+ "should match APS renderer corpus vector {}",
+ vector.id
+ );
+ }
+ }
+
fn parse_with_context(
provider: &ApsAuctionProvider,
response: PlatformResponse,
@@ -1402,20 +2128,20 @@ mod tests {
}
#[test]
- fn config_accepts_canonical_alias_and_integer_ids() {
+ fn config_accepts_canonical_string_and_integer_ids() {
let canonical: ApsConfig = serde_json::from_value(json!({
"account_id": " example-account "
}))
.expect("should parse canonical account ID");
- let alias: ApsConfig =
- serde_json::from_value(json!({"pub_id": 1234})).expect("should parse legacy alias");
+ let integer: ApsConfig =
+ serde_json::from_value(json!({"account_id": 1234})).expect("should parse integer ID");
let debug: ApsConfig = serde_json::from_value(json!({
"account_id": "example-account",
"debug": true
}))
.expect("should parse debug flag");
assert_eq!(canonical.account_id, "example-account");
- assert_eq!(alias.account_id, "1234");
+ assert_eq!(integer.account_id, "1234");
assert!(!canonical.enabled);
assert!(!canonical.debug);
assert!(debug.debug);
@@ -1474,13 +2200,11 @@ mod tests {
)
.is_err()
);
- assert!(
- serde_json::from_value::(json!({
- "account_id": "one",
- "pub_id": "two"
- }))
- .is_err()
- );
+ let legacy_alias = serde_json::Value::Object(serde_json::Map::from_iter([(
+ ["pub", "id"].join("_"),
+ json!("two"),
+ )]));
+ assert!(serde_json::from_value::(legacy_alias).is_err());
for endpoint in [
"http://aps.example/e/pb/bid",
"https://",
@@ -1843,12 +2567,223 @@ mod tests {
let response = provider.parse_aps_response(&value, 12, &request());
assert!(response.bids.is_empty());
assert_eq!(
- response.metadata["drop_reasons"]["unexpected_response_shape"],
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
1
);
}
}
+ #[test]
+ fn upstream_bid_ids_are_required_bounded_control_free_and_response_unique() {
+ let provider = ApsAuctionProvider::new(config());
+ let mut missing = bid("missing", 1.0, "iframe");
+ missing
+ .as_object_mut()
+ .expect("should build an object bid")
+ .remove("id");
+ let empty = bid("", 1.1, "iframe");
+ let oversized = bid(&format!("{}x", "é".repeat(32)), 1.2, "iframe");
+ let control = bid("control\u{0000}id", 1.3, "iframe");
+ let duplicate_low = bid("duplicate", 1.4, "iframe");
+ let duplicate_high = bid("duplicate", 9.0, "iframe");
+ let valid_boundary = bid(&"é".repeat(32), 2.0, "iframe");
+
+ let response = provider.parse_aps_response(
+ &json!({"seatbid": [{"bid": [
+ missing,
+ empty,
+ oversized,
+ control,
+ duplicate_low,
+ duplicate_high,
+ valid_boundary
+ ]}]}),
+ 12,
+ &request(),
+ );
+
+ assert_eq!(response.status, BidStatus::Success);
+ assert_eq!(response.bids.len(), 1);
+ assert_eq!(
+ response.bids[0].bid_id.as_deref(),
+ Some("é".repeat(32).as_str())
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::MissingUpstreamBidId),
+ 2
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::UpstreamBidIdTooLarge),
+ 1
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidUpstreamBidId),
+ 1
+ );
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::DuplicateUpstreamBidId),
+ 2
+ );
+ }
+
+ #[test]
+ fn bid_validation_is_typed_and_isolated_from_a_valid_sibling() {
+ let provider = ApsAuctionProvider::new(config());
+ let mut unknown_imp = bid("unknown-imp", 1.0, "iframe");
+ unknown_imp["impid"] = json!("not-requested");
+ let negative_price = bid("negative-price", -0.1, "iframe");
+ let mut wrong_price = bid("wrong-price", 1.0, "iframe");
+ wrong_price["price"] = json!("1.0");
+ let mut zero_width = bid("zero-width", 1.0, "iframe");
+ zero_width["w"] = json!(0);
+ let mut over_height = bid("over-height", 1.0, "iframe");
+ over_height["h"] = json!(4097);
+ let mut unmatched_size = bid("unmatched-size", 1.0, "iframe");
+ unmatched_size["w"] = json!(728);
+ unmatched_size["h"] = json!(90);
+ let mut missing_url = bid("missing-url", 1.0, "iframe");
+ missing_url["ext"]
+ .as_object_mut()
+ .expect("should build a bid extension")
+ .remove("creativeurl");
+ let mut invalid_url = bid("invalid-url", 1.0, "iframe");
+ invalid_url["ext"]["creativeurl"] = json!("http://creative.example/render");
+ let invalid_tag = bid("invalid-tag", 1.0, "video");
+ let mut invalid_creative_id = bid("invalid-creative-id", 1.0, "iframe");
+ invalid_creative_id["crid"] = json!(42);
+ let mut unsupported_media = bid("unsupported-media", 1.0, "iframe");
+ unsupported_media["mtype"] = json!(2);
+ let valid = bid("valid-sibling", 2.0, "iframe");
+
+ let response = provider.parse_aps_response(
+ &json!({"seatbid": [{"bid": [
+ "malformed",
+ unknown_imp,
+ negative_price,
+ wrong_price,
+ zero_width,
+ over_height,
+ unmatched_size,
+ missing_url,
+ invalid_url,
+ invalid_tag,
+ invalid_creative_id,
+ unsupported_media,
+ valid
+ ]}]}),
+ 12,
+ &request(),
+ );
+
+ assert_eq!(response.bids.len(), 1);
+ assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid-sibling"));
+ for reason in [
+ AuctionDropReason::MalformedBid,
+ AuctionDropReason::UnknownImpression,
+ AuctionDropReason::DimensionsOutOfRange,
+ AuctionDropReason::MissingCreativeUrl,
+ AuctionDropReason::InvalidCreativeUrl,
+ AuctionDropReason::InvalidTagType,
+ AuctionDropReason::InvalidCreativeId,
+ AuctionDropReason::UnsupportedMediaType,
+ ] {
+ assert_eq!(drop_count(&response, reason), 1, "should report {reason:?}");
+ }
+ assert_eq!(drop_count(&response, AuctionDropReason::InvalidPrice), 2);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidDimensions),
+ 2
+ );
+ }
+
+ #[test]
+ fn dimensions_accept_exact_requested_membership_at_contract_boundaries() {
+ let provider = ApsAuctionProvider::new(config());
+ let mut auction_request = request();
+ auction_request.slots = vec![
+ AdSlot {
+ id: "minimum-slot".to_string(),
+ formats: vec![AdFormat {
+ media_type: MediaType::Banner,
+ width: 1,
+ height: 1,
+ }],
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ },
+ AdSlot {
+ id: "maximum-slot".to_string(),
+ formats: vec![AdFormat {
+ media_type: MediaType::Banner,
+ width: 4096,
+ height: 4096,
+ }],
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ },
+ ];
+ let mut minimum = bid("minimum", 1.0, "iframe");
+ minimum["impid"] = json!("minimum-slot");
+ minimum["w"] = json!(1);
+ minimum["h"] = json!(1);
+ let mut maximum = bid("maximum", 1.0, "iframe");
+ maximum["impid"] = json!("maximum-slot");
+ maximum["w"] = json!(4096);
+ maximum["h"] = json!(4096);
+
+ let response = provider.parse_aps_response(
+ &json!({"seatbid": [{"bid": [minimum, maximum]}]}),
+ 12,
+ &auction_request,
+ );
+
+ assert_eq!(response.status, BidStatus::Success);
+ assert_eq!(response.bids.len(), 2);
+ assert_eq!(response.bids[0].slot_id, "minimum-slot");
+ assert_eq!(response.bids[1].slot_id, "maximum-slot");
+ assert_eq!(response.metadata["dropped_bid_count"], 0);
+ }
+
+ #[test]
+ fn contextual_currency_and_nonfinite_json_are_invalid_provider_responses() {
+ let provider = ApsAuctionProvider::new(config());
+ for value in [
+ json!({"contextual": {"slots": []}, "seatbid": [{"bid": [bid("valid", 1.0, "iframe")]}]}),
+ json!({"cur": "EUR", "seatbid": [{"bid": [bid("eur", 1.0, "iframe")]}]}),
+ ] {
+ let response = provider.parse_aps_response(&value, 12, &request());
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
+ 1
+ );
+ }
+
+ let body = br#"{"seatbid":[{"bid":[{"id":"overflow","impid":"fictional-slot","price":1e400,"w":300,"h":250,"ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"}}]}]}"#;
+ let response = futures::executor::block_on(
+ provider.parse_response_inner(
+ PlatformResponse::new(
+ edgezero_core::http::response_builder()
+ .status(StatusCode::OK)
+ .body(EdgeBody::from(body.to_vec()))
+ .expect("should build nonfinite APS response"),
+ ),
+ 12,
+ Some(&request()),
+ None,
+ false,
+ ),
+ )
+ .expect("should reject nonfinite APS JSON safely");
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
+ 1
+ );
+ }
+
#[test]
fn debug_metadata_matches_pbs_httpcalls_shape() {
let mut provider_config = config();
@@ -2036,7 +2971,7 @@ mod tests {
let oversized = parse_with_context(&provider, oversized);
assert_eq!(
- malformed.metadata["drop_reasons"]["unexpected_response_shape"],
+ drop_count(&malformed, AuctionDropReason::InvalidProviderResponse),
1
);
assert_eq!(
@@ -2116,7 +3051,7 @@ mod tests {
assert!(response.bids.is_empty());
assert_eq!(
- response.metadata["drop_reasons"]["unexpected_response_shape"],
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
1
);
}
@@ -2196,15 +3131,18 @@ mod tests {
}
#[test]
- fn unsupported_currency_is_a_no_bid() {
+ fn unsupported_currency_is_an_invalid_provider_response() {
let provider = ApsAuctionProvider::new(config());
let response = provider.parse_aps_response(
&json!({"cur": "EUR", "seatbid": [{"bid": [bid("eur-bid", 1.0, "iframe")]}]}),
12,
&request(),
);
- assert_eq!(response.status, BidStatus::NoBid);
- assert_eq!(response.metadata["drop_reasons"]["unsupported_currency"], 1);
+ assert_eq!(response.status, BidStatus::Error);
+ assert_eq!(
+ drop_count(&response, AuctionDropReason::InvalidProviderResponse),
+ 1
+ );
}
#[test]
@@ -2217,10 +3155,7 @@ mod tests {
);
assert_eq!(response.bids.len(), 1);
assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid"));
- assert_eq!(
- response.metadata["drop_reasons"]["missing_render_source"],
- 1
- );
+ assert_eq!(drop_count(&response, AuctionDropReason::MalformedBid), 1);
}
#[test]
@@ -2296,7 +3231,7 @@ mod tests {
);
assert!(response.bids.is_empty());
assert_eq!(
- response.metadata["drop_reasons"]["missing_render_source"],
+ drop_count(&response, AuctionDropReason::MissingCreativeUrl),
1
);
assert_eq!(response.metadata["drop_reasons"]["invalid_dimensions"], 1);
@@ -2323,6 +3258,8 @@ mod tests {
let mut uppercase_publisher = request();
uppercase_publisher.publisher.domain = "Creative.Example".to_string();
+ uppercase_publisher.publisher.page_url =
+ Some("https://Creative.Example/article".to_string());
let response = provider.parse_aps_response(
&json!({"seatbid": [{"bid": [bid("same-origin", 1.0, "iframe")]}]}),
12,
@@ -2333,47 +3270,7 @@ mod tests {
}
#[test]
- fn registers_and_serves_only_static_renderer_route() {
- let integration = ApsRendererIntegration;
- let routes = integration.routes();
- assert_eq!(routes.len(), 1, "should register one route");
- assert_eq!(routes[0].method, Method::GET);
- assert_eq!(routes[0].path, APS_RENDERER_ROUTE);
-
- let settings = create_test_settings();
- let services = noop_services();
- let request = http::Request::builder()
- .method(Method::GET)
- .uri(APS_RENDERER_ROUTE)
- .body(EdgeBody::empty())
- .expect("should build renderer request");
- let response =
- futures::executor::block_on(integration.handle(&settings, &services, request))
- .expect("should serve renderer");
- assert_eq!(response.status(), StatusCode::OK);
- assert_eq!(
- response.headers()[header::CONTENT_TYPE],
- "text/html; charset=utf-8"
- );
- assert_eq!(response.headers()["x-content-type-options"], "nosniff");
- assert_eq!(response.headers()["referrer-policy"], "no-referrer");
- assert_eq!(
- response.headers()[header::CONTENT_SECURITY_POLICY],
- APS_RENDERER_CSP
- );
-
- let post = http::Request::builder()
- .method(Method::POST)
- .uri(APS_RENDERER_ROUTE)
- .body(EdgeBody::empty())
- .expect("should build method rejection request");
- let response = futures::executor::block_on(integration.handle(&settings, &services, post))
- .expect("should reject unsupported method");
- assert_eq!(response.status(), StatusCode::NOT_FOUND);
- }
-
- #[test]
- fn enabled_config_registers_renderer_proxy() {
+ fn enabled_config_does_not_register_a_second_renderer_proxy() {
let mut settings = create_test_settings();
settings
.integrations
@@ -2388,7 +3285,7 @@ mod tests {
.expect("should return enabled registration");
assert_eq!(registration.integration_id, APS_INTEGRATION_ID);
- assert_eq!(registration.proxies.len(), 1);
+ assert!(registration.proxies.is_empty());
assert!(registration.js_disabled);
}
@@ -2413,38 +3310,466 @@ mod tests {
}
#[test]
- fn renderer_document_is_static_and_nonce_bound() {
- assert!(APS_RENDERER_DOCUMENT.contains("^#tsaps="));
- assert!(APS_RENDERER_DOCUMENT.contains("html,body{margin:0;padding:0;overflow:hidden}"));
- assert!(APS_RENDERER_DOCUMENT.contains("event.source!==parent||event.ports.length!==0"));
- assert!(APS_RENDERER_DOCUMENT.contains("event.ports.length!==1"));
- assert!(APS_RENDERER_DOCUMENT.contains("portEvent.ports.length!==0"));
- assert!(APS_RENDERER_DOCUMENT.contains("message.nonce!==expected"));
- assert!(APS_RENDERER_DOCUMENT.contains("port.postMessage({message:result,nonce:nonce})"));
- assert!(APS_RENDERER_DOCUMENT.contains("prebid/creative/render"));
- assert!(APS_RENDERER_DOCUMENT.contains("window._aps instanceof Map"));
- assert!(APS_RENDERER_DOCUMENT.contains("store:new Map([['listeners',new Map()]])"));
- assert!(APS_RENDERER_DOCUMENT.contains("account.queue.push(new CustomEvent"));
- assert!(
- APS_RENDERER_DOCUMENT.contains("trusted-server/aps/renderer-ready")
- && APS_RENDERER_DOCUMENT.contains("trusted-server/aps/renderer-failed")
+ fn coordinated_cutover_routes_are_reserved_with_exact_local_method_policy() {
+ let enabled = ApsV1Integration { enabled: true };
+ let disabled = ApsV1Integration { enabled: false };
+ let settings = create_test_settings();
+ let services = noop_services();
+
+ for path in [APS_RENDERER_V1_ROUTE, APS_RUNNER_ROUTE] {
+ let disabled_get = http::Request::builder()
+ .method(Method::GET)
+ .uri(path)
+ .body(EdgeBody::empty())
+ .expect("should build disabled APS request");
+ let response =
+ futures::executor::block_on(disabled.handle(&settings, &services, disabled_get))
+ .expect("disabled APS family should answer locally");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+
+ for method in [
+ Method::POST,
+ Method::HEAD,
+ Method::OPTIONS,
+ Method::PUT,
+ Method::PATCH,
+ Method::DELETE,
+ Method::TRACE,
+ Method::CONNECT,
+ Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"),
+ ] {
+ let request = http::Request::builder()
+ .method(method.clone())
+ .uri(path)
+ .body(EdgeBody::empty())
+ .expect("should build APS method rejection");
+ let response =
+ futures::executor::block_on(enabled.handle(&settings, &services, request))
+ .expect("reserved APS family should reject unsupported methods locally");
+ assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
+ assert_eq!(response.headers()[header::ALLOW], "GET");
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+ assert_eq!(response.headers().len(), 2, "method={method} path={path}");
+ assert!(
+ response
+ .into_body()
+ .into_bytes()
+ .unwrap_or_default()
+ .is_empty()
+ );
+ }
+ }
+
+ for path in [
+ "/integrations/aps/renderer",
+ "/integrations/aps/renderer/v2",
+ "/integrations/aps/runner/v1.js",
+ "/integrations/aps/renderer/v1/extra",
+ "/integrations/aps/not-a-route",
+ ] {
+ let request = http::Request::builder()
+ .method(Method::GET)
+ .uri(path)
+ .body(EdgeBody::empty())
+ .expect("should build unknown APS family request");
+ let response =
+ futures::executor::block_on(enabled.handle(&settings, &services, request))
+ .expect("unknown APS family path should answer locally");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND, "path={path}");
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+ }
+ }
+
+ #[test]
+ fn aps_family_classifier_has_an_exact_segment_boundary() {
+ assert!(is_aps_family_path("/integrations/aps"));
+ assert!(is_aps_family_path("/integrations/aps/renderer/v1"));
+ assert!(!is_aps_family_path("/integrations/apsx"));
+ assert!(!is_aps_family_path("/integrations/ap"));
+ }
+
+ #[test]
+ fn coordinated_cutover_renderer_has_exact_immutable_embedding_policy() {
+ let integration = ApsV1Integration { enabled: true };
+ let request = http::Request::builder()
+ .method(Method::GET)
+ .uri(APS_RENDERER_V1_ROUTE)
+ .body(EdgeBody::empty())
+ .expect("should build versioned renderer request");
+ let response = futures::executor::block_on(integration.handle(
+ &create_test_settings(),
+ &noop_services(),
+ request,
+ ))
+ .expect("versioned renderer should be served");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.headers()[header::CONTENT_TYPE],
+ "text/html; charset=utf-8"
);
- assert!(!APS_RENDERER_DOCUMENT.contains("window.apstag"));
- assert!(
- APS_RENDERER_DOCUMENT
- .contains("https://client.aps.amazon-adsystem.com/prebid-creative.js")
- );
- assert!(!APS_RENDERER_DOCUMENT.contains(""#
+ vec![integration_config_script(
+ DIDOMI_INTEGRATION_ID,
+ &config_json,
)]
}
}
@@ -573,8 +574,9 @@ mod tests {
assert_eq!(inserts.len(), 1);
assert_eq!(
inserts[0],
- r#""#
+ r#""#
);
+ assert!(!inserts[0].contains("__tsjs_didomi"));
}
#[test]
@@ -648,7 +650,7 @@ mod tests {
let inserts = integration.head_inserts(&ctx);
assert_eq!(
inserts[0],
- r#""#
+ r#""#
);
}
}
diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js
new file mode 100644
index 000000000..5c8a161f1
--- /dev/null
+++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js
@@ -0,0 +1,138 @@
+// @generated by scripts/generate-aps-renderer-contract.mjs
+// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027
+// corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9
+var DESCRIPTOR_KEYS = ["aaxResponse","accountId","bidId","creativeUrl","height","tagType","type","version","width"];
+var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ["aaxResponse","accountId","bidId","creativeId","creativeUrl","height","tagType","type","version","width"];
+var ENVELOPE_ROOT_KEYS = ["seatbid"];
+var ENVELOPE_SEAT_KEYS = ["bid"];
+var ENVELOPE_BID_KEYS = ["ext","h","id","price","w"];
+var ENVELOPE_EXT_KEYS = ["creativeurl","tagtype"];
+var MAX_ACCOUNT_ID_BYTES = 1024;
+var MAX_BID_ID_BYTES = 64;
+var MAX_CREATIVE_ID_BYTES = 1024;
+var MAX_CREATIVE_URL_BYTES = 4096;
+var MAX_RENDER_ENVELOPE_BYTES = 262144;
+var MAX_RENDER_ENVELOPE_BASE64_BYTES = 349528;
+var STANDARD_BASE64_PATTERN = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$";
+var RENDER_DIMENSION_MIN = 1;
+var RENDER_DIMENSION_MAX = 4096;
+function apsExactRecord(value, expectedKeys) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ var prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) return false;
+ if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false;
+ var actual = Object.getOwnPropertyNames(value).sort();
+ if (actual.length !== expectedKeys.length) return false;
+ for (var index = 0; index < actual.length; index += 1) {
+ var propertyName = actual[index];
+ if (propertyName === undefined || propertyName !== expectedKeys[index]) return false;
+ var property = Object.getOwnPropertyDescriptor(value, propertyName);
+ if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false;
+ }
+ return true;
+}
+
+function apsUtf8Length(value) {
+ return (new TextEncoder()).encode(value).length;
+}
+
+function apsHasAsciiControl(value) {
+ return /[\x00-\x1f\x7f]/.test(value);
+}
+
+function apsDimensionResult(value) {
+ if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) {
+ return 'invalid_dimensions';
+ }
+ if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) {
+ return 'dimensions_out_of_range';
+ }
+ return 'accepted';
+}
+
+function apsValidCreativeUrl(value, publisherOrigin) {
+ try {
+ var url = new URL(value);
+ return url.protocol === 'https:' && url.hostname !== '' && url.username === '' &&
+ url.password === '' && url.origin !== publisherOrigin;
+ } catch (_error) {
+ return false;
+ }
+}
+
+function apsDecodeEnvelope(value) {
+ if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES ||
+ value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) {
+ return undefined;
+ }
+ try {
+ var binary = atob(value);
+ if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined;
+ var bytes = new Uint8Array(binary.length);
+ for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
+ } catch (_error) {
+ return undefined;
+ }
+}
+
+function classifyApsRendererDescriptorV1(
+ value
+) {
+ var renderer = value;
+ if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) &&
+ !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid';
+ if (renderer.type !== 'aps' || renderer.version !== 1 ||
+ typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 ||
+ apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES ||
+ typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 ||
+ apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES ||
+ apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid';
+ if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') &&
+ (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 ||
+ apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid';
+ if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid';
+
+ var widthResult = apsDimensionResult(renderer.width);
+ if (widthResult !== 'accepted') return widthResult;
+ var heightResult = apsDimensionResult(renderer.height);
+ if (heightResult !== 'accepted') return heightResult;
+
+ if (typeof renderer.creativeUrl !== 'string' ||
+ apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES ||
+ typeof renderer.aaxResponse !== 'string' ||
+ renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid';
+ return 'accepted';
+}
+
+function classifyApsRendererV1(
+ value,
+ publisherOrigin
+) {
+ var renderer = value;
+ var descriptorResult =
+ classifyApsRendererDescriptorV1(renderer);
+ if (descriptorResult !== 'accepted') return descriptorResult;
+ if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid';
+
+ var decoded = apsDecodeEnvelope(renderer.aaxResponse);
+ if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) ||
+ decoded.seatbid.length !== 1) return 'descriptor_invalid';
+ var seat = decoded.seatbid[0];
+ if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) ||
+ seat.bid.length !== 1) return 'descriptor_invalid';
+ var bid = seat.bid[0];
+ if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) ||
+ !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid';
+
+ var bidWidthResult = apsDimensionResult(bid.w);
+ if (bidWidthResult !== 'accepted') return bidWidthResult;
+ var bidHeightResult = apsDimensionResult(bid.h);
+ if (bidHeightResult !== 'accepted') return bidHeightResult;
+ if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height ||
+ bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType ||
+ typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) {
+ return 'descriptor_invalid';
+ }
+ return 'accepted';
+}
diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs
index 3e8f021fe..dc5f68a29 100644
--- a/crates/trusted-server-core/src/integrations/gpt.rs
+++ b/crates/trusted-server-core/src/integrations/gpt.rs
@@ -469,57 +469,12 @@ impl IntegrationHeadInjector for GptIntegration {
GPT_INTEGRATION_ID
}
- /// Injects the `tsjs.adInit` bootstrap script into ``.
- ///
- /// ## Scroll / refresh handoff contract (Phase 1)
- ///
- /// `tsjs.adInit` handles **initial render only**: it wires server-side bid
- /// targeting into GPT slots and refreshes them. Win/billing beacons fire
- /// only from the TS render bridge in the JS bundle, where a matching
- /// Prebid Universal Creative request proves the TS creative rendered.
- /// It does **not** trigger refresh auctions or handle GPT slot refresh events.
- ///
- /// Post-`window.load`, slim-Prebid owns scroll and GPT refresh: it listens
- /// for GPT refresh events, runs client-side auctions, and sets targeting for
- /// subsequent impressions. SPA navigation is handled separately by
- /// `installSpaAuctionHook()` in the GPT bundle, which re-runs the server-side
- /// auction via `GET /_ts/page-bids` on pushState / replaceState / popstate
- /// route changes (see `auction/endpoints.rs`).
- /// The `POST /auction` endpoint is not involved in scroll or refresh flows.
+ /// GPT startup is owned entirely by the release-matched TS integration.
fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec {
- let mut scripts = vec![
- ""
- .to_string(),
- format!("", GPT_BOOTSTRAP_JS),
- ];
-
- if let Some(ref url) = self.config.slim_prebid_url {
- // JSON-encode the URL, then escape `` so a configured value
- // containing the literal `` cannot close this inline tag and
- // let trailing markup execute (standard JSON-in-HTML mitigation).
- let encoded = serde_json::to_string(url)
- .expect("should serialize string")
- .replace("", "<\\/");
- scripts.push(format!(
- ""
- ));
- }
-
- scripts
+ Vec::new()
}
}
-/// Inline `window.tsjs.adInit` bootstrap injected at `` so the bids
-/// script at `` can call it before the TSJS bundle has loaded.
-///
-/// The bundle's idempotent implementation in
-/// `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` later overwrites this stub.
-/// Both implementations guard the one-time-per-page setup with
-/// `window.tsjs.servicesEnabled` so neither double-enables services if the
-/// publisher's own init code also calls `googletag.enableServices()`.
-const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js");
-
// Default value functions
fn default_enabled() -> bool {
@@ -1128,7 +1083,7 @@ mod tests {
// -- Head injector --
#[test]
- fn head_injector_emits_enable_flag() {
+ fn head_injector_is_empty_because_the_ts_module_owns_gpt_startup() {
let integration = GptIntegration::new(test_config());
let doc_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
@@ -1140,216 +1095,7 @@ mod tests {
let inserts = integration.head_inserts(&ctx);
- assert_eq!(inserts.len(), 2, "should emit exactly two head inserts");
- assert_eq!(
- inserts[0],
- "",
- "should set the enable flag and call the GPT shim activation function"
- );
- }
-
- #[test]
- fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() {
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let inserts = integration.head_inserts(&ctx);
- let combined = inserts.join("");
- assert!(combined.contains("ts.adInit"), "should define tsjs.adInit");
- assert!(
- combined.contains("ts.bids"),
- "should read tsjs.bids synchronously"
- );
- assert!(
- combined.contains("ts_initial"),
- "should set ts_initial sentinel"
- );
- assert!(
- !combined.contains("addEventListener(\"slotRenderEnded\""),
- "inline bootstrap cannot prove TS creative rendering from GPT slotRenderEnded"
- );
- assert!(
- !combined.contains("sendBeacon"),
- "inline bootstrap must not fire win/billing beacons from GPT slotRenderEnded"
- );
- assert!(
- !combined.contains("getTargeting(\"hb_adid\")"),
- "inline bootstrap must not treat GPT targeting as winner proof"
- );
- assert!(
- !combined.contains("/ts-bids"),
- "must NOT fetch /ts-bids — bids are inline on the page"
- );
- assert!(
- !combined.contains("bidsPromise"),
- "must NOT use bidsPromise — bids are synchronous"
- );
- assert!(
- !combined.contains("__ts_request_id"),
- "must NOT reference request_id — no longer used"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_installs_fallback_scheduler() {
- // The `` bids script hands its payload to
- // `tsjs.scheduleInitialAdInit`. The bundle installs the real scheduler,
- // but when the bundle fails to load this head bootstrap must provide
- // the degradation path — otherwise a failed bundle request would leave
- // initial server-side ads uninitialized. Executable coverage of the
- // fallback lives in the Vitest suite (gpt_bootstrap.test.ts).
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("ts.scheduleInitialAdInit"),
- "should install the fallback scheduler for bundle-load failures"
- );
- assert!(
- combined.contains("navGeneration"),
- "fallback scheduler should honor the navigation-generation guard"
- );
- assert!(
- combined.contains("requestAnimationFrame"),
- "fallback scheduler should defer past hydration frames"
- );
- assert!(
- combined.contains("\"load\""),
- "fallback scheduler should gate on window load"
- );
- // The no-retry-timer property is owned by the executable suite
- // (gpt_bootstrap.test.ts asserts adInit runs exactly once); a
- // `!contains("setTimeout")` over the whole joined head-insert output
- // would misattribute any future unrelated timer to the scheduler.
- }
-
- #[test]
- fn head_inserts_bootstrap_uses_css_safe_div_prefix_lookup() {
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("querySelectorAll(\"[id]\")"),
- "bootstrap should scan ID-bearing elements instead of interpolating div_id into CSS"
- );
- assert!(
- combined.contains("candidate.id.startsWith(divId)"),
- "bootstrap should match metacharacter-containing div_id prefixes with startsWith"
- );
- assert!(
- !combined.contains("[id^='\" + slot.div_id"),
- "bootstrap must not build a CSS attribute selector from raw div_id"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_installs_inner_div_slot_handoff() {
- let integration = GptIntegration::new(test_config());
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("gptSlotHandoffs"),
- "bootstrap should keep late publisher slot handoff state on window.tsjs"
- );
- assert!(
- combined.contains("__tsSlotHandoffPatched"),
- "bootstrap should install idempotent GPT handoff wrappers"
- );
- assert!(
- combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"),
- "bootstrap should define the TS fallback on the actual inner div"
- );
- assert!(
- !combined.contains("actualDivId + \"-container\""),
- "bootstrap must not define a competing outer-container GPT slot"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() {
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("ts.servicesEnabled"),
- "should guard enableServices/enableSingleRequest with the tsjs.servicesEnabled flag"
- );
- assert!(combined.contains("ts.adInit"), "should install tsjs.adInit");
- assert!(
- !combined.contains("googletag.pubads().refresh()"),
- "should never call unbounded refresh() — only refresh(newSlots)"
- );
- }
-
- #[test]
- fn head_inserts_bootstrap_refreshes_ts_slots_when_initial_load_disabled() {
- // Mirrors the bundle: when the publisher disables initial load through
- // setConfig() or the legacy disableInitialLoad() method, display() only
- // registers a TS-defined slot, so the bootstrap must also refresh those
- // slots or they render blank.
- let config = test_config();
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
- let combined = integration.head_inserts(&ctx).join("");
- assert!(
- combined.contains("gpt.setConfig"),
- "bootstrap should wrap googletag.setConfig() to detect the disabled state"
- );
- assert!(
- combined.contains("gpt.getConfig"),
- "bootstrap should read GPT's modern initial-load configuration"
- );
- assert!(
- combined.contains("pubads.disableInitialLoad"),
- "bootstrap should wrap legacy disableInitialLoad() calls"
- );
- assert!(
- combined.contains("gptInitialLoadDisabled"),
- "bootstrap should record the initial-load-disabled state on window.tsjs"
- );
- assert!(
- combined.contains("slotsNeedingRefresh"),
- "bootstrap should refresh TS-defined slots when initial load is disabled"
- );
+ assert!(inserts.is_empty(), "must not retain a second GPT bootstrap");
}
#[test]
@@ -1360,97 +1106,4 @@ mod tests {
"gpt"
);
}
-
- #[test]
- fn head_inserts_emits_slim_prebid_url_when_configured() {
- let config = GptConfig {
- slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()),
- ..test_config()
- };
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
-
- assert_eq!(
- inserts.len(),
- 3,
- "should emit three head inserts when slim_prebid_url is set"
- );
- assert_eq!(
- inserts[2],
- r#""#,
- "should emit the slim-Prebid URL as a JSON-encoded string assignment"
- );
- }
-
- #[test]
- fn head_inserts_escapes_script_terminator_in_slim_prebid_url() {
- // A configured URL containing `` must not close the inline tag.
- let config = GptConfig {
- slim_prebid_url: Some("https://cdn.example.com/x ".to_string()),
- ..test_config()
- };
- let integration = GptIntegration::new(config);
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
-
- // The injected ` ` must be neutralised: the only
- // `` left is the tag's own legitimate closer.
- assert!(
- !inserts[2].contains(" terminator, got: {}",
- inserts[2]
- );
- assert_eq!(
- inserts[2].matches("").count(),
- 1,
- "only the tag's own closing should remain, got: {}",
- inserts[2]
- );
- assert!(
- inserts[2].contains("<\\/script>"),
- "should emit the escaped terminator, got: {}",
- inserts[2]
- );
- }
-
- #[test]
- fn head_inserts_omits_slim_prebid_url_when_not_configured() {
- let integration = GptIntegration::new(test_config());
- let doc_state = IntegrationDocumentState::default();
- let ctx = IntegrationHtmlContext {
- request_host: "edge.example.com",
- request_scheme: "https",
- origin_host: "example.com",
- document_state: &doc_state,
- };
-
- let inserts = integration.head_inserts(&ctx);
-
- assert_eq!(
- inserts.len(),
- 2,
- "should emit exactly two head inserts when slim_prebid_url is absent"
- );
- assert!(
- inserts
- .iter()
- .all(|s| !s.contains("__tsjs_slim_prebid_url")),
- "should not emit slim-Prebid URL tag when not configured"
- );
- }
}
diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js
deleted file mode 100644
index a57e5cf18..000000000
--- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js
+++ /dev/null
@@ -1,495 +0,0 @@
-// Edge-injected GPT auction bootstrap.
-//
-// This is the minimal `window.tsjs.adInit` that runs on first page load
-// before the TSJS bundle has had a chance to install its richer
-// idempotent implementation. The bundle in
-// crates/trusted-server-js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit`
-// once it loads.
-//
-// Contract with the bundle:
-// - Both implementations must set `window.tsjs.servicesEnabled = true`
-// after calling `enableSingleRequest()`/`enableServices()` so a
-// subsequent call becomes a no-op.
-// - `refresh()` is called only for the slots defined in this pass,
-// never the global slot list.
-//
-// Only installed if `window.tsjs.adInit` isn't already defined.
-(function () {
- if (typeof window === "undefined") return;
- var ts = (window.tsjs = window.tsjs || {});
- if (ts.adInit) return;
-
- // Track whether the publisher disabled GPT initial load. Read the effective
- // googletag.getConfig() value when available, and wrap googletag.setConfig()
- // and the legacy pubads().disableInitialLoad() method so changes are
- // synchronized immediately and still detected when getConfig() is
- // unavailable. With initial load disabled, display() only registers a slot
- // and the ad request must come from a later refresh(); adInit() reads this to
- // refresh its own freshly defined
- // slots so they are not left blank. Pushed onto the command queue so it runs
- // before the publisher's own GPT configuration.
- function syncInitialLoadDisabled(gpt) {
- if (typeof gpt.getConfig !== "function") return false;
- var config = gpt.getConfig("disableInitialLoad");
- if (!config || typeof config.disableInitialLoad === "undefined") {
- return false;
- }
- ts.gptInitialLoadDisabled = config.disableInitialLoad === true;
- return true;
- }
-
- (window.googletag = window.googletag || { cmd: [] }).cmd.push(function () {
- var gpt = window.googletag;
- syncInitialLoadDisabled(gpt);
- if (
- typeof gpt.setConfig === "function" &&
- !gpt.__tsInitialLoadConfigHooked
- ) {
- var originalSetConfig = gpt.setConfig.bind(gpt);
- gpt.setConfig = function (config) {
- var result = originalSetConfig.apply(gpt, arguments);
- if (
- !syncInitialLoadDisabled(gpt) &&
- config &&
- "disableInitialLoad" in config
- ) {
- ts.gptInitialLoadDisabled = config.disableInitialLoad === true;
- }
- return result;
- };
- gpt.__tsInitialLoadConfigHooked = true;
- }
-
- var pubads = gpt.pubads && gpt.pubads();
- if (
- !pubads ||
- typeof pubads.disableInitialLoad !== "function" ||
- pubads.__tsInitialLoadHooked
- ) {
- return;
- }
- var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads);
- pubads.disableInitialLoad = function () {
- var result = originalDisableInitialLoad.apply(pubads, arguments);
- if (!syncInitialLoadDisabled(gpt)) {
- ts.gptInitialLoadDisabled = true;
- }
- return result;
- };
- pubads.__tsInitialLoadHooked = true;
- });
-
- function findSlotByElementId(pubads, elementId) {
- var slots = pubads.getSlots ? pubads.getSlots() : [];
- return (
- slots.find(function (slot) {
- return slot.getSlotElementId() === elementId;
- }) || null
- );
- }
-
- function normalizedGptFormats(formats) {
- return formats.length === 2 &&
- formats.every(function (format) {
- return typeof format === "number";
- })
- ? [formats]
- : formats;
- }
-
- function handoffFormatsMatch(handoff, formats) {
- return (
- JSON.stringify(handoff.formats) ===
- JSON.stringify(normalizedGptFormats(formats))
- );
- }
-
- function matchingHandoff(pubads, adUnitPath, formats, elementId) {
- var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId];
- if (exact) return exact.publisherClaimed ? null : exact;
-
- var candidates = Object.values(ts.gptSlotHandoffs || {}).filter(
- function (handoff, index, allHandoffs) {
- return (
- allHandoffs.indexOf(handoff) === index &&
- !handoff.publisherClaimed &&
- !document.getElementById(handoff.slotElementId) &&
- elementId.startsWith(handoff.divIdPrefix) &&
- handoff.gamUnitPath === adUnitPath &&
- handoffFormatsMatch(handoff, formats) &&
- findSlotByElementId(pubads, handoff.slotElementId)
- );
- },
- );
- return candidates.length === 1 ? candidates[0] : null;
- }
-
- function displayTargetElementId(target) {
- if (typeof target === "string") return target;
- if (target && typeof target.getSlotElementId === "function") {
- return target.getSlotElementId();
- }
- return target && target.id ? target.id : null;
- }
-
- function isElementVisible(element) {
- var style = window.getComputedStyle(element);
- return (
- style.display !== "none" &&
- style.visibility !== "hidden" &&
- style.visibility !== "collapse"
- );
- }
-
- function slotElementHasLayout(element) {
- if (!isElementVisible(element)) return false;
- var elementRect = element.getBoundingClientRect();
- if (elementRect.width > 0 && elementRect.height > 0) return true;
-
- var container = document.getElementById(element.id + "-container");
- if (!container || !isElementVisible(container)) return false;
- var containerRect = container.getBoundingClientRect();
- return containerRect.width > 0 && containerRect.height > 0;
- }
-
- function findSlotElementByDivId(divId) {
- if (!divId) return null;
- var exact = document.getElementById(divId);
- if (exact) return exact;
-
- var idElements = document.querySelectorAll("[id]");
- var prefixMatches = [];
- for (var i = 0; i < idElements.length; i++) {
- var candidate = idElements[i];
- if (
- candidate.id.startsWith(divId) &&
- !candidate.id.endsWith("-container")
- ) {
- prefixMatches.push(candidate);
- }
- }
- // A unique prefix match may be a lazy slot that has not been sized yet.
- // Geometry is only needed to disambiguate multiple responsive siblings.
- if (prefixMatches.length === 1) return prefixMatches[0];
-
- var visibleMatches = prefixMatches.filter(isElementVisible);
- if (visibleMatches.length === 1) return visibleMatches[0];
-
- var activeMatches = visibleMatches.filter(slotElementHasLayout);
- if (activeMatches.length === 1) return activeMatches[0];
-
- if (
- prefixMatches.length > 1 &&
- ts.log &&
- typeof ts.log.warn === "function"
- ) {
- ts.log.warn("GPT slot prefix did not resolve to one active element", {
- divId: divId,
- prefixMatchCount: prefixMatches.length,
- activeMatchCount: activeMatches.length,
- });
- }
- return null;
- }
-
- function runHandoffInternal(callback) {
- var wasInternal = ts.gptSlotHandoffInternal;
- ts.gptSlotHandoffInternal = true;
- try {
- return callback();
- } finally {
- ts.gptSlotHandoffInternal = wasInternal;
- }
- }
-
- // TS cannot wait an arbitrary amount of time for a framework to define a
- // slot: publishers that never define one would render blank. Instead, TS
- // defines its fallback on the actual inner div and aliases only a later
- // publisher defineSlot() for that exact div, or a hydration-renamed replacement
- // after the original div is gone, to the same GPT slot.
- function installSlotHandoff() {
- window.googletag.cmd.push(function () {
- var tag = window.googletag;
- var pubads = tag.pubads && tag.pubads();
- if (!tag.defineSlot || !tag.display || !pubads) return;
-
- if (!tag.defineSlot.__tsSlotHandoffPatched) {
- var originalDefineSlot = tag.defineSlot.bind(tag);
- var patchedDefineSlot = function (adUnitPath, formats, elementId) {
- if (!ts.gptSlotHandoffInternal && typeof elementId === "string") {
- var handoff = matchingHandoff(
- pubads,
- adUnitPath,
- formats,
- elementId,
- );
- if (handoff) {
- var existingSlot = findSlotByElementId(
- pubads,
- handoff.slotElementId,
- );
- if (existingSlot) {
- ts.gptSlotHandoffs[elementId] = handoff;
- handoff.publisherClaimed = true;
- // The supported publisher lifecycle is defineSlot → addService → display.
- // Intentionally wait for that display instead of applying a time heuristic.
- handoff.suppressPublisherDisplay = true;
- handoff.suppressPublisherRefresh =
- ts.gptInitialLoadDisabled === true;
- ts.prevGptSlots = (ts.prevGptSlots || []).filter(
- function (ownedSlot) {
- return ownedSlot !== existingSlot;
- },
- );
- if (
- handoff.gamUnitPath !== adUnitPath ||
- !handoffFormatsMatch(handoff, formats)
- ) {
- ts.log &&
- ts.log.warn &&
- ts.log.warn(
- "GPT slot handoff: publisher definition differs from TS configuration",
- elementId,
- );
- }
- return existingSlot;
- }
- }
- }
- return elementId === undefined
- ? originalDefineSlot(adUnitPath, formats)
- : originalDefineSlot(adUnitPath, formats, elementId);
- };
- patchedDefineSlot.__tsSlotHandoffPatched = true;
- tag.defineSlot = patchedDefineSlot;
- }
-
- if (!tag.display.__tsSlotHandoffPatched) {
- var originalDisplay = tag.display.bind(tag);
- var patchedDisplay = function (target) {
- var elementId = displayTargetElementId(target);
- var handoff =
- elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId];
- if (
- !ts.gptSlotHandoffInternal &&
- handoff &&
- handoff.suppressPublisherDisplay
- ) {
- handoff.suppressPublisherDisplay = false;
- return;
- }
- originalDisplay(target);
- };
- patchedDisplay.__tsSlotHandoffPatched = true;
- tag.display = patchedDisplay;
- }
-
- if (!pubads.refresh.__tsSlotHandoffPatched) {
- var originalRefresh = pubads.refresh.bind(pubads);
- var callRefresh = function (slots, options) {
- if (options === undefined) {
- originalRefresh(slots);
- } else {
- originalRefresh(slots, options);
- }
- };
- var patchedRefresh = function (requestedSlots, options) {
- if (ts.gptSlotHandoffInternal) {
- callRefresh(requestedSlots, options);
- return;
- }
- var slots =
- requestedSlots || (pubads.getSlots ? pubads.getSlots() : null);
- if (!slots) {
- callRefresh(requestedSlots, options);
- return;
- }
- var suppressed = false;
- var remainingSlots = slots.filter(function (slot) {
- var handoff =
- ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()];
- if (!handoff || !handoff.suppressPublisherRefresh) return true;
- handoff.suppressPublisherRefresh = false;
- suppressed = true;
- return false;
- });
- if (!suppressed) {
- callRefresh(requestedSlots, options);
- } else if (remainingSlots.length > 0) {
- callRefresh(remainingSlots, options);
- }
- };
- patchedRefresh.__tsSlotHandoffPatched = true;
- pubads.refresh = patchedRefresh;
- }
- });
- }
-
- installSlotHandoff();
-
- // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's
- // hydration-safe scheduler in
- // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the
- // bids script hands the SSR bids payload to this scheduler, which applies
- // it and runs adInit only while the page is still on navigation
- // generation 0 (the SSR document), after window load plus a double
- // requestAnimationFrame so the call lands outside React's hydration
- // window. Keeps initial server-side ads working when the main TSJS bundle
- // fails to load; the bundle overwrites this with the full implementation.
- //
- // Hidden documents: rAF is not serviced while the document is hidden, so a
- // background-tab load holds the initial adInit until first view. Intended,
- // and deliberately identical to the bundle scheduler — the impression is
- // spent on a viewed tab, and the post-hydration guarantee holds whenever
- // the request is actually issued.
- ts.scheduleInitialAdInit = function (initialBids) {
- if ((ts.navGeneration || 0) !== 0) return;
- if (initialBids) ts.bids = initialBids;
- var fire = function () {
- if ((ts.navGeneration || 0) !== 0) return;
- if (typeof ts.adInit === "function") ts.adInit();
- };
- var afterFrames = function () {
- window.requestAnimationFrame(function () {
- window.requestAnimationFrame(fire);
- });
- };
- if (document.readyState === "complete") afterFrames();
- else window.addEventListener("load", afterFrames, { once: true });
- };
-
- ts.adInit = function () {
- var slots = ts.adSlots || [];
- var bids = ts.bids || {};
- var divToSlotId = {};
- // Generation this invocation belongs to. The slot work below is queued on
- // googletag.cmd, which drains only when GPT loads; recheck first inside
- // the queued callback so a navigation committed in the gap cancels the
- // stale mutation — mirrors the bundle's adInit.
- var generation = ts.navGeneration || 0;
-
- googletag.cmd.push(function () {
- if ((ts.navGeneration || 0) !== generation) return;
- // Slots TS defined itself — tracked for SPA destroy. Publisher-owned
- // slots are reused but never destroyed by TS on navigation.
- var newSlots = [];
- // Publisher-owned slots TS reused — refreshed to pick up server-side
- // targeting. The publisher already display()ed these.
- var slotsToRefresh = [];
- // Element IDs of slots TS defined itself. GPT requires display() to
- // register/render a freshly-defined slot; refresh() alone no-ops for a
- // slot that was never displayed, so these are display()ed instead.
- var slotsToDisplay = [];
- slots.forEach(function (slot) {
- // Resolve actual div ID: exact match first, then the one active prefix
- // match. Responsive publishers may emit several mutually exclusive
- // siblings for one stable prefix, so document order is not sufficient.
- var el = findSlotElementByDivId(slot.div_id);
- if (!el) return;
- var actualDivId = el.id;
- var b = bids[slot.id] || {};
-
- var existingSlots = googletag.pubads().getSlots();
- var s =
- existingSlots.find(function (gs) {
- return gs.getSlotElementId() === actualDivId;
- }) || null;
- var tsOwned = false;
- if (!s) {
- // Define TS's fallback on the publisher's actual div. The scoped
- // handoff wrapper returns this slot if the publisher defines it later.
- s = runHandoffInternal(function () {
- return googletag.defineSlot(
- slot.gam_unit_path,
- slot.formats,
- actualDivId,
- );
- });
- if (!s) return;
- s.addService(googletag.pubads());
- tsOwned = true;
- ts.gptSlotHandoffs = ts.gptSlotHandoffs || {};
- ts.gptSlotHandoffs[actualDivId] = {
- gamUnitPath: slot.gam_unit_path,
- formats: slot.formats,
- divIdPrefix: slot.div_id,
- slotElementId: actualDivId,
- publisherClaimed: false,
- suppressPublisherDisplay: false,
- suppressPublisherRefresh: false,
- };
- }
-
- Object.entries(slot.targeting || {}).forEach(function (e) {
- s.setTargeting(e[0], e[1]);
- });
- [
- "hb_pb",
- "hb_bidder",
- "hb_adid",
- "hb_cache_host",
- "hb_cache_path",
- ].forEach(function (k) {
- if (b[k]) s.setTargeting(k, b[k]);
- });
- // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts
- s.setTargeting("ts_initial", "1");
- // Map the resolved inner div to the slot ID. This bootstrap fires no
- // beacons and registers no slotRenderEnded listener; the map is consumed
- // by the bundle's render bridge (index.ts) once it loads.
- divToSlotId[actualDivId] = slot.id;
- var slotElementId = s.getSlotElementId();
- if (slotElementId && slotElementId !== actualDivId) {
- divToSlotId[slotElementId] = slot.id;
- }
- if (tsOwned) {
- newSlots.push(s);
- var displayId = s.getSlotElementId() || actualDivId;
- slotsToDisplay.push(displayId);
- } else {
- slotsToRefresh.push(s);
- }
- });
- ts.prevGptSlots = newSlots;
- ts.divToSlotId = divToSlotId;
- if (!ts.servicesEnabled) {
- googletag.pubads().enableSingleRequest();
- googletag.enableServices();
- ts.servicesEnabled = true;
- }
- // Register and render TS-defined slots. GPT requires display() for a
- // freshly-defined slot; without it the slot no-ops and misses its
- // impression. Runs after enableServices(); on SPA navigation services are
- // already enabled, so this runs unconditionally for new slots.
- slotsToDisplay.forEach(function (divId) {
- runHandoffInternal(function () {
- googletag.display(divId);
- });
- });
- // Reused publisher-owned slots always need a refresh to pick up the
- // server-side targeting. TS-defined slots are fetched by display() above
- // unless the publisher disabled initial load, in which case display() only
- // registers them and refresh() must request the ad — otherwise they render
- // blank. Only add them in that case to avoid double-requesting.
- syncInitialLoadDisabled(window.googletag);
- var slotsNeedingRefresh = ts.gptInitialLoadDisabled
- ? slotsToRefresh.concat(newSlots)
- : slotsToRefresh;
- if (slotsNeedingRefresh.length > 0) {
- // One-shot bypass: this internal refresh delivers the just-applied
- // server-side targeting to GAM. If slim-Prebid has already wrapped
- // refresh(), it must pass this call straight through — not clear the
- // targeting and run a duplicate client-side auction. Mirrors the
- // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts.
- ts.adInitRefreshInProgress = true;
- try {
- runHandoffInternal(function () {
- googletag.pubads().refresh(slotsNeedingRefresh);
- });
- } finally {
- ts.adInitRefreshInProgress = false;
- }
- }
- });
- };
-})();
diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs
index 5bd86d19e..15c596329 100644
--- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs
+++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs
@@ -1,9 +1,9 @@
//! Query-activated, browser-session GPT runtime diagnostics integration.
//!
-//! Deployment configuration makes the standalone browser module available.
-//! Exact `ts_console` directives establish or clear a host-only session cookie;
-//! active documents load the module synchronously without adding diagnostics to
-//! the ordinary unified bundle.
+//! Exact `ts_console` directives establish or clear a host-only session cookie.
+//! Active documents select `gpt_diagnostics` as a critical member of the unified
+//! TSJS manifest, whose runtime owns diagnostics fact capture and delivery. No
+//! standalone diagnostics browser module or script tag is emitted.
use error_stack::{Report, ResultExt};
use http::{HeaderValue, Method, Request, Response, Uri, header, uri::PathAndQuery};
@@ -16,7 +16,6 @@ use crate::error::TrustedServerError;
use crate::http_util::is_navigation_request;
use crate::response_privacy::CDN_CACHE_HEADERS;
use crate::settings::{IntegrationConfig, Settings};
-use crate::tsjs;
use super::IntegrationRegistration;
@@ -26,7 +25,6 @@ pub const GPT_DIAGNOSTICS_INTEGRATION_ID: &str = "gpt_diagnostics";
pub const GPT_DIAGNOSTICS_QUERY: &str = "ts_console";
/// Host-only browser-session activation cookie.
pub const GPT_DIAGNOSTICS_COOKIE: &str = "__Host-ts-console";
-
const SET_CONSOLE_COOKIE: &str = "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax";
const CLEAR_CONSOLE_COOKIE: &str =
"__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0";
@@ -35,7 +33,7 @@ const CLEAR_CONSOLE_COOKIE: &str =
#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct GptDiagnosticsConfig {
- /// Whether the GPT diagnostics browser module is available.
+ /// Whether requests may activate the unified critical GPT diagnostics runtime.
#[serde(default)]
pub enabled: bool,
}
@@ -62,6 +60,7 @@ pub enum GptDiagnosticsCookieAction {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GptDiagnosticsRequestDecision {
active: bool,
+ reserved_directive: bool,
clean_browser_path_and_query: Option,
cookie_action: GptDiagnosticsCookieAction,
}
@@ -73,44 +72,31 @@ impl GptDiagnosticsRequestDecision {
self.active
}
+ /// Serialize the exact `DiagnosticsBootV1.gpt` value for the boot emitter.
+ #[must_use]
+ pub fn boot_config_json(&self) -> &'static str {
+ if self.active {
+ r#"{"active":true}"#
+ } else {
+ r#"{"active":false}"#
+ }
+ }
+
/// Whether the response must be private and non-storeable.
#[must_use]
pub fn requires_private_no_store(&self) -> bool {
self.active
|| self.cookie_action != GptDiagnosticsCookieAction::None
- || self.clean_browser_path_and_query.is_some()
- }
-
- /// Build the early activation/URL-cleanup bootstrap for an HTML document.
- #[must_use]
- pub fn bootstrap_script(&self) -> Option {
- if !self.active && self.clean_browser_path_and_query.is_none() {
- return None;
- }
-
- let mut script = String::from("");
- Some(script)
+ || self.reserved_directive
}
- /// Build the synchronous standalone diagnostics module tag.
+ /// Build the one-time inline URL-cleanup tag after reserved input was consumed.
#[must_use]
- pub fn module_script_tag(&self) -> Option {
- self.active.then(|| {
- format!(
- "",
- tsjs::tsjs_single_module_script_src(GPT_DIAGNOSTICS_INTEGRATION_ID)
- )
- })
+ pub fn url_cleanup_script_tag(&self) -> Option {
+ let clean_path = serde_json::to_string(self.clean_browser_path_and_query.as_ref()?).ok()?;
+ Some(format!(
+ ""
+ ))
}
}
@@ -198,9 +184,13 @@ pub fn prepare_request(
replace_path_and_query(request, &clean_path)?;
}
- let mut decision = GptDiagnosticsRequestDecision::default();
+ let mut decision = GptDiagnosticsRequestDecision {
+ reserved_directive: had_reserved_query,
+ clean_browser_path_and_query: (eligible_navigation && had_reserved_query)
+ .then_some(clean_path),
+ ..GptDiagnosticsRequestDecision::default()
+ };
if integration_enabled && eligible_navigation && had_reserved_query {
- decision.clean_browser_path_and_query = Some(clean_path);
match directive {
QueryDirective::Enable => {
decision.active = true;
@@ -239,6 +229,7 @@ pub fn finalize_response(
decision: &GptDiagnosticsRequestDecision,
response: &mut Response,
) {
+ sanitize_console_set_cookie(response);
let cookie = match decision.cookie_action {
GptDiagnosticsCookieAction::None => None,
GptDiagnosticsCookieAction::SetSession => {
@@ -299,9 +290,9 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState {
for cookie in value.split(';') {
let cookie = cookie.trim();
match cookie.split_once('=') {
- Some((name, value)) if name.trim() == GPT_DIAGNOSTICS_COOKIE => {
+ Some((name, value)) if name == GPT_DIAGNOSTICS_COOKIE => {
state.occurrences += 1;
- state.canonical |= value.trim() == "1";
+ state.canonical |= value == "1";
}
None if cookie == GPT_DIAGNOSTICS_COOKIE => state.occurrences += 1,
_ => {}
@@ -311,6 +302,33 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState {
state
}
+fn sanitize_console_set_cookie(response: &mut Response) {
+ let retained = response
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .filter(|value| {
+ let pair = value
+ .as_bytes()
+ .split(|byte| *byte == b';')
+ .next()
+ .unwrap_or_default();
+ let name = pair
+ .split(|byte| *byte == b'=')
+ .next()
+ .unwrap_or_default()
+ .trim_ascii();
+ name != GPT_DIAGNOSTICS_COOKIE.as_bytes()
+ })
+ .cloned()
+ .collect::>();
+
+ response.headers_mut().remove(header::SET_COOKIE);
+ for value in retained {
+ response.headers_mut().append(header::SET_COOKIE, value);
+ }
+}
+
fn sanitize_console_cookie(request: &mut Request) {
let retained = request
.headers()
@@ -386,7 +404,7 @@ mod tests {
}
#[test]
- fn register_excludes_diagnostics_from_unified_and_deferred_bundles() {
+ fn ts_console_register_defers_browser_delivery_to_the_unified_critical_catalog() {
let registry = IntegrationRegistry::new(&settings(true)).expect("should build registry");
assert!(registry.integration_enabled(GPT_DIAGNOSTICS_INTEGRATION_ID));
@@ -403,7 +421,7 @@ mod tests {
}
#[test]
- fn exact_directive_activates_cleans_and_strips_cookie() {
+ fn ts_console_exact_directive_activates_cleans_and_strips_cookie() {
let mut request = navigation(
"https://publisher.example/page?keep=%2F&ts_console=true#fragment",
Some("other=value; __Host-ts-console=1"),
@@ -421,13 +439,18 @@ mod tests {
"https://publisher.example/page?keep=%2F"
);
assert_eq!(request.headers()[header::COOKIE], "other=value");
- let bootstrap = decision.bootstrap_script().expect("should bootstrap");
- assert!(bootstrap.contains("__tsjs_gpt_diagnostics_active=true"));
- assert!(bootstrap.contains("/page?keep=%2F"));
+ assert_eq!(decision.boot_config_json(), r#"{"active":true}"#);
+ let cleanup = decision
+ .url_cleanup_script_tag()
+ .expect("a server-consumed directive should authorize inline cleanup");
+ assert!(cleanup.starts_with(""));
+ assert!(cleanup.contains("history.replaceState"));
+ assert!(!cleanup.contains(" src="));
}
#[test]
- fn prefetch_directive_is_sanitized_without_activating_session() {
+ fn ts_console_prefetch_directive_is_sanitized_without_activating_session() {
let mut request = navigation("https://publisher.example/page?ts_console=1&keep=1", None);
request
.headers_mut()
@@ -444,13 +467,14 @@ mod tests {
}
#[test]
- fn active_cookie_enables_clean_navigation_but_duplicates_fail_closed() {
+ fn ts_console_active_cookie_enables_clean_navigation_but_duplicates_fail_closed() {
let mut active = navigation(
"https://publisher.example/page",
Some("__Host-ts-console=1; other=value"),
);
let decision = prepare_request(&settings(true), &mut active).expect("should prepare");
assert!(decision.active());
+ assert_eq!(decision.url_cleanup_script_tag(), None);
assert_eq!(active.headers()[header::COOKIE], "other=value");
let mut duplicate = navigation(
@@ -460,10 +484,17 @@ mod tests {
let decision = prepare_request(&settings(true), &mut duplicate).expect("should prepare");
assert!(!decision.active());
assert_eq!(duplicate.headers()[header::COOKIE], "other=value");
+
+ for noncanonical in ["__Host-ts-console =1", "__Host-ts-console= 1"] {
+ let mut request = navigation("https://publisher.example/page", Some(noncanonical));
+ let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
+ assert!(!decision.active(), "{noncanonical} must fail closed");
+ assert!(!request.headers().contains_key(header::COOKIE));
+ }
}
#[test]
- fn invalid_duplicate_and_disable_directives_fail_closed() {
+ fn ts_console_invalid_duplicate_and_disable_directives_fail_closed() {
for query in [
"ts_console=True",
"ts_console=",
@@ -492,7 +523,47 @@ mod tests {
}
#[test]
- fn finalization_sets_cookie_and_strips_shared_cache_headers() {
+ fn ts_console_invalid_directive_is_private_without_mutating_the_session() {
+ let mut request = navigation(
+ "https://publisher.example/page?keep=1&ts_console=True",
+ Some("__Host-ts-console=1"),
+ );
+ let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
+ let mut response = Response::builder()
+ .header(header::CACHE_CONTROL, "public, max-age=60")
+ .body(EdgeBody::empty())
+ .expect("should build response");
+
+ finalize_response(&decision, &mut response);
+
+ assert!(!decision.active());
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert!(!response.headers().contains_key(header::SET_COOKIE));
+ assert_eq!(
+ response.headers()[header::CACHE_CONTROL],
+ "private, no-store"
+ );
+ }
+
+ #[test]
+ fn ts_console_is_disabled_by_default_while_reserved_input_is_still_sanitized() {
+ let mut request = navigation(
+ "https://publisher.example/page?keep=1&ts_console=1",
+ Some("__Host-ts-console=1; other=value"),
+ );
+
+ let decision = prepare_request(&settings(false), &mut request).expect("should prepare");
+
+ assert!(!decision.active());
+ assert_eq!(decision.boot_config_json(), r#"{"active":false}"#);
+ assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None);
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert_eq!(request.headers()[header::COOKIE], "other=value");
+ assert!(decision.requires_private_no_store());
+ }
+
+ #[test]
+ fn ts_console_finalization_sets_cookie_and_strips_shared_cache_headers() {
let mut request = navigation("https://publisher.example/?ts_console=1", None);
let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
let mut response = Response::builder()
@@ -500,6 +571,11 @@ mod tests {
.header("surrogate-control", "max-age=60")
.header("fastly-surrogate-control", "max-age=60")
.header("cloudflare-cdn-cache-control", "public, max-age=60")
+ .header(header::SET_COOKIE, "publisher=value; Path=/")
+ .header(
+ header::SET_COOKIE,
+ "__Host-ts-console=origin; Path=/; Secure",
+ )
.body(EdgeBody::empty())
.expect("should build response");
@@ -509,7 +585,13 @@ mod tests {
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
- assert_eq!(response.headers()[header::SET_COOKIE], SET_CONSOLE_COOKIE);
+ let cookies = response
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .map(|value| value.to_str().expect("should emit valid cookie text"))
+ .collect::>();
+ assert_eq!(cookies, vec!["publisher=value; Path=/", SET_CONSOLE_COOKIE]);
assert!(!response.headers().contains_key("surrogate-control"));
assert!(!response.headers().contains_key("fastly-surrogate-control"));
assert!(
@@ -520,7 +602,51 @@ mod tests {
}
#[test]
- fn config_rejects_unknown_fields() {
+ fn ts_console_only_eligible_get_document_navigations_activate() {
+ for (method, destination) in [(Method::POST, "document"), (Method::GET, "script")] {
+ let mut request = Request::builder()
+ .method(method.clone())
+ .uri("https://publisher.example/page?keep=1&ts_console=1")
+ .header("sec-fetch-dest", destination)
+ .header(header::COOKIE, "__Host-ts-console=1; other=value")
+ .body(EdgeBody::empty())
+ .expect("should build ineligible request");
+
+ let decision = prepare_request(&settings(true), &mut request)
+ .expect("should sanitize ineligible request");
+
+ assert!(
+ !decision.active(),
+ "{method} {destination} must not activate"
+ );
+ assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None);
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert_eq!(request.headers()[header::COOKIE], "other=value");
+ }
+ }
+
+ #[test]
+ fn ts_console_disable_emits_the_exact_session_cookie_clear() {
+ let mut request = navigation(
+ "https://publisher.example/page?ts_console=0&keep=1",
+ Some("__Host-ts-console=1"),
+ );
+ let decision = prepare_request(&settings(true), &mut request).expect("should prepare");
+ let mut response = Response::new(EdgeBody::empty());
+
+ finalize_response(&decision, &mut response);
+
+ assert!(!decision.active());
+ assert_eq!(request.uri().query(), Some("keep=1"));
+ assert_eq!(response.headers()[header::SET_COOKIE], CLEAR_CONSOLE_COOKIE);
+ assert_eq!(
+ response.headers()[header::CACHE_CONTROL],
+ "private, no-store"
+ );
+ }
+
+ #[test]
+ fn ts_console_config_rejects_unknown_fields() {
let mut settings = create_test_settings();
settings
.integrations
diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs
index 90d688693..3f22c72a8 100644
--- a/crates/trusted-server-core/src/integrations/mod.rs
+++ b/crates/trusted-server-core/src/integrations/mod.rs
@@ -38,6 +38,23 @@ pub use registry::{
RequestFilterRegistryOutcome, ScriptRewriteAction,
};
+/// Emit one integration value into the sole transient pre-core config transport.
+///
+/// `config_json` must already be serialized and escaped for an inline script.
+/// The production core snapshots this object, freezes each value, deletes the
+/// transport, and then supplies the matching value only to its manifest module.
+pub(crate) fn integration_config_script(integration_id: &'static str, config_json: &str) -> String {
+ debug_assert!(
+ integration_id
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte == b'_'),
+ "integration config id should be a trusted lowercase identifier"
+ );
+ format!(
+ ""
+ )
+}
+
/// Registers or retrieves a platform backend for the given URL.
///
/// Parses `url`, builds a [`PlatformBackendSpec`] with TLS enabled and a
@@ -99,6 +116,31 @@ pub(crate) fn ensure_integration_backend_with_timeout(
})
}
+/// Registers or retrieves a platform backend with separate response-header and
+/// response-body gap timeouts.
+///
+/// # Errors
+///
+/// Returns an error when `url` cannot be parsed, is missing a host, or the
+/// backend registration fails.
+pub(crate) fn ensure_integration_backend_with_transport_timeouts(
+ services: &RuntimeServices,
+ url: &str,
+ integration: &'static str,
+ first_byte_timeout: Duration,
+ between_bytes_timeout: Duration,
+) -> Result> {
+ let mut spec = integration_backend_spec(url, integration, true, first_byte_timeout)?;
+ spec.between_bytes_timeout = between_bytes_timeout;
+ services
+ .backend()
+ .ensure(&spec)
+ .change_context(TrustedServerError::Integration {
+ integration: integration.to_string(),
+ message: "Failed to register backend".to_string(),
+ })
+}
+
/// Compute the deterministic platform backend name for a URL without registering it.
///
/// Parses `url`, builds a [`PlatformBackendSpec`], and delegates to
diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs
index 1fd14526f..2328746db 100644
--- a/crates/trusted-server-core/src/integrations/prebid.rs
+++ b/crates/trusted-server-core/src/integrations/prebid.rs
@@ -31,7 +31,8 @@ use crate::integrations::{
AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter,
IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy,
IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded,
- ensure_integration_backend_with_timeout, predict_integration_backend_name,
+ ensure_integration_backend_with_timeout, integration_config_script,
+ predict_integration_backend_name,
};
use crate::openrtb::{
Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, ImpStoredRequest,
@@ -204,9 +205,8 @@ pub struct PrebidIntegrationConfig {
pub enabled: bool,
#[validate(url)]
pub server_url: String,
- /// Prebid Server account ID, injected into the client-side bundle via
- /// `window.__tsjs_prebid.accountId` so publishers don't need to configure
- /// it in JavaScript.
+ /// Prebid Server account ID delivered through the release-bound immutable
+ /// integration configuration so publishers do not configure it in JavaScript.
#[serde(default)]
pub account_id: Option,
#[serde(default = "default_timeout_ms")]
@@ -1100,8 +1100,9 @@ impl IntegrationHeadInjector for PrebidIntegration {
})
.replace("", "<\\/");
- let mut inserts = vec![format!(
- r#""#
+ let mut inserts = vec![integration_config_script(
+ PREBID_INTEGRATION_ID,
+ &config_json,
)];
inserts.push(self.external_bundle_script_tag());
@@ -2367,9 +2368,8 @@ impl PrebidAuctionProvider {
// not an ad ID, so it is not used as a fallback: surfacing it as `ad_id`
// (which is exposed raw in the debug bid) would mislead any consumer that
// treats `ad_id` as a creative identifier. Absent `adid`, `ad_id` is None.
- // The bid ID is carried separately in `bid_id` instead. An empty `id` is
- // treated as absent — a blank hb_adid is falsey on the page, so it would
- // be no better than omitting the key.
+ // The bid ID is carried separately in `bid_id` instead. Treat a blank
+ // value as absent so it cannot become an unusable browser identity.
let bid_id = bid_obj
.get("id")
.and_then(|v| v.as_str())
@@ -2438,6 +2438,9 @@ impl PrebidAuctionProvider {
Ok(AuctionBid {
slot_id,
+ candidate_id: None,
+ candidate_provider: None,
+ renderer_reservation_id: None,
price: Some(price), // Prebid provides decoded prices
currency: DEFAULT_CURRENCY.to_string(),
creative,
@@ -2517,7 +2520,7 @@ impl AuctionProvider for PrebidAuctionProvider {
context,
signer_with_signature
.as_ref()
- .map(|(s, sig, params)| (s, sig.clone(), params)),
+ .map(|(signer, signature, params)| (signer, signature.clone(), params)),
request_info,
);
@@ -3079,7 +3082,7 @@ server_url = "https://prebid.example/openrtb2/auction"
}
#[test]
- fn startup_validation_and_runtime_build_canonicalize_excluded_gam_ad_unit_path_suffixes() {
+ fn startup_validation_canonicalizes_excluded_gam_ad_unit_path_suffixes() {
let mut settings = make_settings();
settings
.integrations
@@ -3108,9 +3111,7 @@ server_url = "https://prebid.example/openrtb2/auction"
"should retain only the first declaration of each suffix"
);
- let integration = build(&settings)
- .expect("should build Prebid integration")
- .expect("should return enabled Prebid integration");
+ let integration = PrebidIntegration::new(config);
let document_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
@@ -4025,6 +4026,9 @@ external_bundle_sri = "sha384-AAAA"
"should omit empty refresh-auction exclusions: {}",
script
);
+ assert!(script.contains("c.prebid="));
+ assert!(!script.contains("window.__tsjs_prebid"));
+ assert!(!script.contains("window.pbjs"));
}
#[test]
@@ -5767,6 +5771,11 @@ external_bundle_sri = "sha384-AAAA"
provider_responses: vec![provider_response],
mediator_response: None,
winning_bids: HashMap::new(),
+ decision_set: crate::auction::types::AuctionDecisionSetV1 {
+ version: 1,
+ auction_id: "test-auction".to_string(),
+ results: Vec::new(),
+ },
total_time_ms: 42,
metadata: HashMap::new(),
};
@@ -7799,8 +7808,6 @@ set = { networkId = 42 }
#[test]
fn parse_bid_treats_blank_bid_id_as_absent() {
- // A blank hb_adid is falsey on the page, so carrying an empty `id`
- // forward would be no better than omitting the field.
let bid_json = serde_json::json!({
"id": "",
"impid": "atf_sidebar_ad",
@@ -7812,6 +7819,7 @@ set = { networkId = 42 }
let bid = provider
.parse_bid(&bid_json, "example-bidder")
.expect("should parse bid");
+
assert!(
bid.bid_id.is_none(),
"should treat an empty OpenRTB bid id as absent"
diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs
index 291a54242..69f3fe4ee 100644
--- a/crates/trusted-server-core/src/integrations/registry.rs
+++ b/crates/trusted-server-core/src/integrations/registry.rs
@@ -693,6 +693,7 @@ struct IntegrationRegistryInner {
patch_router: Router,
head_router: Router,
options_router: Router,
+ reserved_proxies: Vec<(&'static str, Arc)>,
// Metadata for introspection
routes: Vec<(IntegrationEndpoint, &'static str)>,
@@ -716,6 +717,7 @@ impl Default for IntegrationRegistryInner {
patch_router: Router::new(),
head_router: Router::new(),
options_router: Router::new(),
+ reserved_proxies: Vec::new(),
routes: Vec::new(),
enabled_integration_ids: Vec::new(),
deferred_js_ids: Vec::new(),
@@ -740,6 +742,16 @@ pub struct IntegrationMetadata {
pub request_filters: usize,
}
+/// Request/document-owned inputs for generated TSJS catalog predicates.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct TsjsCatalogSelectionV1 {
+ pub creative_enabled: bool,
+ pub creative_click_guard: bool,
+ pub creative_render_guard: bool,
+ pub gpt_diagnostics_active: bool,
+ pub render_trace_overlay: bool,
+}
+
impl IntegrationMetadata {
fn new(id: &'static str) -> Self {
Self {
@@ -786,6 +798,11 @@ impl IntegrationRegistry {
/// Panics if a route path ends with `/*` but `strip_suffix` unexpectedly fails (invariant violation).
pub fn new(settings: &Settings) -> Result> {
let mut inner = IntegrationRegistryInner::default();
+ let aps_proxy: Arc =
+ Arc::new(super::aps::ApsV1Integration::from_settings(settings)?);
+ inner
+ .reserved_proxies
+ .push(("/integrations/aps", aps_proxy));
for builder in crate::integrations::builders() {
if let Some(registration) = (builder.build)(settings)? {
@@ -867,6 +884,37 @@ impl IntegrationRegistry {
})
}
+ fn reserved_proxy(&self, path: &str) -> Option<&Arc> {
+ self.inner
+ .reserved_proxies
+ .iter()
+ .find(|(family, _)| {
+ path == *family
+ || path
+ .strip_prefix(*family)
+ .is_some_and(|suffix| suffix.starts_with('/'))
+ })
+ .map(|(_, proxy)| proxy)
+ }
+
+ /// Return true when a hard-cutover family owns this path.
+ #[must_use]
+ pub fn has_reserved_path(&self, path: &str) -> bool {
+ self.reserved_proxy(path).is_some()
+ }
+
+ /// Dispatch a hard-cutover family before auth, EC, filters, and fallback.
+ #[must_use]
+ pub async fn handle_reserved_proxy(
+ &self,
+ settings: &Settings,
+ services: &RuntimeServices,
+ req: Request,
+ ) -> Option, Report>> {
+ let proxy = self.reserved_proxy(req.uri().path())?;
+ Some(proxy.handle(settings, services, req).await)
+ }
+
fn find_route(&self, method: &Method, path: &str) -> Option<&RouteValue> {
let router = match *method {
Method::GET => &self.inner.get_router,
@@ -1158,6 +1206,109 @@ impl IntegrationRegistry {
.collect()
}
+ /// Return enabled TSJS catalog modules in generated release order.
+ #[must_use]
+ pub fn tsjs_catalog_module_ids(&self, selection: TsjsCatalogSelectionV1) -> Vec<&'static str> {
+ self.tsjs_selected_catalog_metadata(selection)
+ .into_iter()
+ .map(|metadata| metadata.id)
+ .collect()
+ }
+
+ /// Return the enabled parser-blocking catalog slice in canonical order.
+ #[must_use]
+ pub fn tsjs_critical_module_ids(&self, selection: TsjsCatalogSelectionV1) -> Vec<&'static str> {
+ self.tsjs_selected_catalog_metadata(selection)
+ .into_iter()
+ .filter(|metadata| metadata.phase == Some(trusted_server_js::TsjsModulePhase::Critical))
+ .map(|metadata| metadata.id)
+ .collect()
+ }
+
+ /// Return the enabled post-paint catalog slice in canonical order.
+ #[must_use]
+ pub fn tsjs_deferred_module_ids(&self, selection: TsjsCatalogSelectionV1) -> Vec<&'static str> {
+ self.tsjs_selected_catalog_metadata(selection)
+ .into_iter()
+ .filter(|metadata| metadata.phase == Some(trusted_server_js::TsjsModulePhase::Deferred))
+ .map(|metadata| metadata.id)
+ .collect()
+ }
+
+ /// Return the bounded request-owned variants admitted by static transport.
+ #[must_use]
+ pub fn tsjs_static_transport_selections(
+ &self,
+ render_trace_overlay: bool,
+ ) -> Vec {
+ let diagnostics_values: &[bool] = if self.integration_enabled("gpt_diagnostics") {
+ &[false, true]
+ } else {
+ &[false]
+ };
+ let mut selections = Vec::with_capacity(diagnostics_values.len() * 2);
+ for creative_enabled in [false, true] {
+ for gpt_diagnostics_active in diagnostics_values {
+ selections.push(TsjsCatalogSelectionV1 {
+ creative_enabled,
+ creative_click_guard: creative_enabled,
+ creative_render_guard: false,
+ gpt_diagnostics_active: *gpt_diagnostics_active,
+ render_trace_overlay,
+ });
+ }
+ }
+ selections
+ }
+
+ fn tsjs_catalog_module_enabled(
+ &self,
+ predicate: Option<&str>,
+ selection: TsjsCatalogSelectionV1,
+ ) -> bool {
+ match predicate {
+ Some("always") => true,
+ Some("creative_guard") => {
+ selection.creative_enabled
+ && (selection.creative_click_guard || selection.creative_render_guard)
+ }
+ Some("gpt_diagnostics_active") => selection.gpt_diagnostics_active,
+ Some("diagnostics_presentation") => {
+ selection.render_trace_overlay || selection.gpt_diagnostics_active
+ }
+ Some("prebid_and_gpt") => {
+ self.integration_enabled("prebid") && self.integration_enabled("gpt")
+ }
+ Some(predicate) => predicate
+ .strip_prefix("integration:")
+ .is_some_and(|integration_id| self.integration_enabled(integration_id)),
+ None => false,
+ }
+ }
+
+ fn tsjs_selected_catalog_metadata(
+ &self,
+ selection: TsjsCatalogSelectionV1,
+ ) -> Vec {
+ let mut selected = Vec::new();
+ let mut provided = std::collections::HashSet::from(["runtime.v1"]);
+ for metadata in trusted_server_js::all_integration_metadata() {
+ if !self.tsjs_catalog_module_enabled(metadata.include, selection) {
+ continue;
+ }
+ let requirements_available = metadata
+ .inputs
+ .iter()
+ .all(|declaration| declaration.contains('?') || provided.contains(declaration));
+ if !requirements_available {
+ continue;
+ }
+ provided.extend(metadata.outputs.iter().copied());
+ selected.push(metadata);
+ }
+ selected
+ }
+
#[cfg(test)]
#[must_use]
pub fn empty_for_tests() -> Self {
@@ -1181,6 +1332,7 @@ impl IntegrationRegistry {
patch_router: Router::new(),
head_router: Router::new(),
options_router: Router::new(),
+ reserved_proxies: Vec::new(),
routes: Vec::new(),
enabled_integration_ids: Vec::new(),
html_rewriters: attribute_rewriters,
@@ -1210,6 +1362,7 @@ impl IntegrationRegistry {
patch_router: Router::new(),
head_router: Router::new(),
options_router: Router::new(),
+ reserved_proxies: Vec::new(),
routes: Vec::new(),
enabled_integration_ids: Vec::new(),
html_rewriters: attribute_rewriters,
@@ -1235,6 +1388,7 @@ impl IntegrationRegistry {
patch_router: Router::new(),
head_router: Router::new(),
options_router: Router::new(),
+ reserved_proxies: Vec::new(),
routes: Vec::new(),
enabled_integration_ids: Vec::new(),
html_rewriters: Vec::new(),
@@ -1300,6 +1454,7 @@ impl IntegrationRegistry {
patch_router,
head_router,
options_router,
+ reserved_proxies: Vec::new(),
routes: Vec::new(),
enabled_integration_ids: Vec::new(),
html_rewriters: Vec::new(),
@@ -1462,6 +1617,34 @@ mod tests {
);
}
+ #[test]
+ fn production_registry_always_reserves_the_aps_family() {
+ let settings = create_test_settings();
+ let registry =
+ IntegrationRegistry::new(&settings).expect("production registry should build");
+ assert!(registry.has_reserved_path("/integrations/aps"));
+ assert!(registry.has_reserved_path("/integrations/aps/runner.js"));
+ assert!(registry.has_reserved_path("/integrations/aps/malformed/path"));
+ assert!(!registry.has_reserved_path("/integrations/apsx/runner.js"));
+ assert!(!registry.has_reserved_path("/integrations/aps-legacy"));
+
+ let request = Request::builder()
+ .method(Method::GET)
+ .uri("/integrations/aps/renderer/v1")
+ .header(HEADER_X_TS_EC.clone(), "caller-controlled")
+ .body(EdgeBody::empty())
+ .expect("should build reserved APS request");
+ let response = futures::executor::block_on(registry.handle_reserved_proxy(
+ &settings,
+ &noop_services(),
+ request,
+ ))
+ .expect("reserved family should be handled")
+ .expect("disabled APS response should be local");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
+ }
+
#[test]
fn filter_request_applies_request_headers_and_returns_response_headers() {
let registry =
@@ -2035,7 +2218,7 @@ mod tests {
}
#[test]
- fn js_module_ids_include_explicitly_enabled_cmp_mirrors() {
+ fn catalog_ids_split_explicitly_enabled_cmp_owners_by_phase() {
let mut settings = crate::test_support::tests::create_test_settings();
settings
.integrations
@@ -2047,15 +2230,25 @@ mod tests {
.expect("should insert osano config");
let registry = IntegrationRegistry::new(&settings).expect("should create registry");
- let immediate = registry.js_module_ids_immediate();
+ let selection = TsjsCatalogSelectionV1::default();
+ let critical = registry.tsjs_critical_module_ids(selection);
+ let deferred = registry.tsjs_deferred_module_ids(selection);
assert!(
- immediate.contains(&"sourcepoint"),
- "should include Sourcepoint when explicitly enabled"
+ critical.contains(&"sourcepoint_consent"),
+ "should include the Sourcepoint consent owner when explicitly enabled"
+ );
+ assert!(
+ critical.contains(&"osano_consent"),
+ "should include the Osano consent owner when explicitly enabled"
+ );
+ assert!(
+ deferred.contains(&"sourcepoint_lifecycle"),
+ "should defer the Sourcepoint lifecycle owner"
);
assert!(
- immediate.contains(&"osano"),
- "should include Osano when explicitly enabled"
+ deferred.contains(&"osano_lifecycle"),
+ "should defer the Osano lifecycle owner"
);
let metadata = registry.registered_integrations();
diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs
index 3caaadeef..ccd576e6c 100644
--- a/crates/trusted-server-core/src/integrations/sourcepoint.rs
+++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs
@@ -37,7 +37,7 @@ use crate::integrations::{
AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext,
IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationHeadInjector,
IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, collect_body_bounded,
- collect_response_bounded, ensure_integration_backend,
+ collect_response_bounded, ensure_integration_backend, integration_config_script,
};
use crate::platform::{PlatformHttpRequest, RuntimeServices};
use crate::settings::{IntegrationConfig, Settings};
@@ -1019,66 +1019,10 @@ impl IntegrationHeadInjector for SourcepointIntegration {
}
fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec {
- let mut inserts = vec![format!(
- "",
- self.config.rewrite_sdk
- )];
-
- if !self.config.rewrite_sdk {
- return inserts;
- }
-
- // Install a property trap on `window._sp_` so that when the
- // publisher's code (typically a Next.js hydration chunk) sets the
- // Sourcepoint config object, we intercept it and rewrite any
- // `cdn.privacy-mgmt.com` URLs to the first-party proxy prefix.
- //
- // The trap is transparent: the getter returns the (patched) value and
- // the setter accepts any shape the SDK expects. We also handle the
- // case where `window._sp_` is already set before our script runs.
- //
- // Limitations:
- // - Only intercepts top-level assignment (`window._sp_ = …`). Nested
- // mutation like `window._sp_.config.baseEndpoint = "…"` after the
- // initial assignment is not caught. The JS body regex rewriter
- // covers that case for string literals in bundled code.
- // - `s.replace()` replaces only the first occurrence per call, which
- // is fine for the current set of scalar URL config fields.
- inserts.push(format!(
- concat!(
- "",
- ),
- cdn_host = SOURCEPOINT_CDN_HOST,
- cdn_prefix = SOURCEPOINT_CDN_PREFIX,
- ));
-
- inserts
+ vec![integration_config_script(
+ SOURCEPOINT_INTEGRATION_ID,
+ &format!("{{\"rewriteSdk\":{}}}", self.config.rewrite_sdk),
+ )]
}
}
@@ -1444,7 +1388,7 @@ mod tests {
}
#[test]
- fn head_injector_emits_config_script_plus_trap_when_enabled() {
+ fn head_injector_emits_only_transient_config_when_enabled() {
let integration = SourcepointIntegration::new(Arc::new(config(true)));
let document_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
@@ -1455,53 +1399,15 @@ mod tests {
};
let inserts = integration.head_inserts(&ctx);
- assert_eq!(
- inserts.len(),
- 2,
- "should emit config plus trap script when enabled"
- );
+ assert_eq!(inserts.len(), 1, "the TS module owns the Sourcepoint guard");
let config_script = &inserts[0];
assert!(
- config_script.contains("window.__tsjs_sourcepoint={\"rewriteSdk\":true}"),
+ config_script.contains("c.sourcepoint={\"rewriteSdk\":true}"),
"should emit rewrite SDK config script: {config_script}"
);
-
- let trap_script = &inserts[1];
- assert!(
- trap_script.starts_with(""),
- "should be wrapped in script tags: {trap_script}",
- );
- assert!(
- trap_script.contains("cdn.privacy-mgmt.com"),
- "should reference the CDN host to rewrite: {trap_script}",
- );
- assert!(
- trap_script.contains("/integrations/sourcepoint/cdn"),
- "should contain the first-party CDN prefix: {trap_script}",
- );
- assert!(
- trap_script.contains("try{") && trap_script.contains("catch(e)"),
- "should guard best-effort trap installation: {trap_script}",
- );
- assert!(
- trap_script.contains("console.warn"),
- "should log trap installation failures for observability: {trap_script}",
- );
- assert!(
- trap_script.contains("Object.defineProperty"),
- "should install a property trap on window._sp_: {trap_script}",
- );
- for config_field in ["baseEndpoint", "mmsDomain", "wrapperAPIOrigin", "cmpOrigin"] {
- assert!(
- trap_script.contains(&format!("o.config.{config_field}")),
- "should patch config field {config_field}: {trap_script}",
- );
- }
- assert!(
- trap_script.contains("o.metricUrl"),
- "should patch top-level metricUrl: {trap_script}",
- );
+ assert!(!config_script.contains("window.__tsjs_sourcepoint"));
+ assert!(!config_script.contains("Object.defineProperty"));
}
#[test]
@@ -1524,7 +1430,7 @@ mod tests {
"should emit only config script when rewrite_sdk is false"
);
assert!(
- inserts[0].contains("window.__tsjs_sourcepoint={\"rewriteSdk\":false}"),
+ inserts[0].contains("c.sourcepoint={\"rewriteSdk\":false}"),
"should flag rewriteSdk false"
);
assert!(
diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs
index 4aded7488..c27a5c988 100644
--- a/crates/trusted-server-core/src/openrtb.rs
+++ b/crates/trusted-server-core/src/openrtb.rs
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
-use crate::auction::types::{BidRenderer, OrchestratorExt};
+use crate::auction::types::{BidRenderSourceV1, OrchestratorExt};
pub type OpenRtbRequest = trusted_server_openrtb::BidRequest;
pub type OpenRtbResponse = trusted_server_openrtb::BidResponse;
@@ -180,7 +180,7 @@ impl ToExt for BidExt<'_> {}
#[derive(Debug, Serialize)]
pub struct BidTrustedServerExt<'a> {
- pub renderer: &'a BidRenderer,
+ pub renderer: &'a BidRenderSourceV1,
}
#[derive(Debug, Serialize)]
diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs
index c93cd757d..c1c3711ed 100644
--- a/crates/trusted-server-core/src/platform/http.rs
+++ b/crates/trusted-server-core/src/platform/http.rs
@@ -1,5 +1,6 @@
use std::any::Any;
use std::fmt;
+use std::time::Duration;
use edgezero_core::http::{Request as EdgeRequest, Response as EdgeResponse};
use error_stack::Report;
@@ -7,6 +8,64 @@ use error_stack::Report;
use super::PlatformError;
use super::image_optimizer::PlatformImageOptimizerOptions;
+/// Raw evidence for one security-relevant upstream response header.
+///
+/// Values are never split or normalized. A runtime that preserves duplicate
+/// fields returns every occurrence; a runtime that visibly combines fields
+/// returns that exact combined byte string; erased evidence is unavailable.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ProxyHeaderEvidenceV1 {
+ /// Every raw header occurrence, including an empty vector for known absence.
+ Occurrences(Vec>),
+ /// One exact runtime-combined value. Core must not split it.
+ Combined(Vec),
+ /// The runtime erased or ambiguously transformed the evidence.
+ Unavailable,
+}
+
+impl ProxyHeaderEvidenceV1 {
+ /// Known absence of the header.
+ #[must_use]
+ pub fn absent() -> Self {
+ Self::Occurrences(Vec::new())
+ }
+
+ /// One preserved raw header occurrence.
+ #[must_use]
+ pub fn one(value: impl Into>) -> Self {
+ Self::Occurrences(vec![value.into()])
+ }
+}
+
+/// Status and raw security-header evidence captured before adapter normalization.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProxyResponseEvidenceV1 {
+ pub status: u16,
+ pub content_type: ProxyHeaderEvidenceV1,
+ pub content_encoding: ProxyHeaderEvidenceV1,
+ pub content_length: ProxyHeaderEvidenceV1,
+}
+
+/// Dedicated bounded raw-proxy transport policy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RawProxyPolicyV1 {
+ /// Monotonic deadline covering dispatch through the final body byte.
+ pub total_timeout: Duration,
+ /// Maximum time the transport may wait for response headers.
+ pub first_byte_timeout: Duration,
+ /// Maximum duration of one blocking response-body read.
+ pub blocking_read_timeout: Duration,
+ /// Maximum accepted identity-body bytes.
+ pub max_response_bytes: usize,
+}
+
+/// Byte-preserving response produced by the dedicated raw-proxy transport.
+#[derive(Debug)]
+pub struct RawProxyResponseV1 {
+ pub evidence: ProxyResponseEvidenceV1,
+ pub body: Vec,
+}
+
/// Outbound HTTP request paired with a pre-resolved backend name.
///
/// Uses `EdgeZero`'s neutral [`EdgeRequest`] type so adapters share one
@@ -266,6 +325,21 @@ pub trait PlatformHttpClient: Send + Sync {
request: PlatformHttpRequest,
) -> Result>;
+ /// Send one response-evidence-preserving, byte-bounded raw proxy request.
+ ///
+ /// Adapters must disable redirects and transformations, capture the three
+ /// evidence headers before generic normalization, enforce the total
+ /// monotonic deadline and byte cap, and cancel/drop in-flight resources on
+ /// failure. The default fails closed for platforms without that contract.
+ async fn send_raw_proxy_v1(
+ &self,
+ _request: PlatformHttpRequest,
+ _policy: RawProxyPolicyV1,
+ ) -> Result> {
+ Err(Report::new(PlatformError::Unsupported)
+ .attach("bounded raw-proxy transport is unavailable on this platform"))
+ }
+
/// Start an upstream request without waiting for it to complete.
///
/// # Errors
diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs
index 287f1accf..a064c4f4f 100644
--- a/crates/trusted-server-core/src/platform/mod.rs
+++ b/crates/trusted-server-core/src/platform/mod.rs
@@ -45,7 +45,8 @@ pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformK
pub use error::PlatformError;
pub use http::{
PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse,
- PlatformSelectResult, UnavailableHttpClient,
+ PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1,
+ RawProxyResponseV1, UnavailableHttpClient,
};
pub use image_optimizer::{
PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions,
@@ -53,6 +54,7 @@ pub use image_optimizer::{
};
pub use kv::UnavailableKvStore;
pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore};
+pub use types::ExactResponseHeadersV1;
pub use types::{
ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId,
StoreName,
diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs
index 10389c787..250f36bde 100644
--- a/crates/trusted-server-core/src/platform/test_support.rs
+++ b/crates/trusted-server-core/src/platform/test_support.rs
@@ -11,7 +11,8 @@ use super::{
ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError,
PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerOptions,
PlatformImageOptimizerParams, PlatformPendingRequest, PlatformResponse, PlatformSecretStore,
- PlatformSelectResult, RuntimeServices, StoreId, StoreName,
+ PlatformSelectResult, RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId,
+ StoreName,
};
use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME};
@@ -234,6 +235,8 @@ pub(crate) struct StubHttpClient {
request_uris: Mutex>,
// Outgoing request bodies captured per send call, collected to bytes.
request_bodies: Mutex>>,
+ raw_proxy_responses: Mutex>,
+ raw_proxy_policies: Mutex>,
}
struct StubHttpResponse {
@@ -257,6 +260,8 @@ impl StubHttpClient {
request_methods: Mutex::new(Vec::new()),
request_uris: Mutex::new(Vec::new()),
request_bodies: Mutex::new(Vec::new()),
+ raw_proxy_responses: Mutex::new(VecDeque::new()),
+ raw_proxy_policies: Mutex::new(Vec::new()),
}
}
@@ -298,6 +303,22 @@ impl StubHttpClient {
});
}
+ /// Queue one response for the dedicated raw-proxy transport boundary.
+ pub fn push_raw_proxy_response(&self, response: RawProxyResponseV1) {
+ self.raw_proxy_responses
+ .lock()
+ .expect("should lock raw proxy responses")
+ .push_back(response);
+ }
+
+ /// Return raw-proxy policies captured per dedicated send.
+ pub fn recorded_raw_proxy_policies(&self) -> Vec {
+ self.raw_proxy_policies
+ .lock()
+ .expect("should lock raw proxy policies")
+ .clone()
+ }
+
/// Inject a `select()` error: the next call to `select()` will return
/// `ready: Err(...)` with the failed request's backend name in
/// `failed_backend_name`. The corresponding queued response is consumed.
@@ -467,6 +488,77 @@ impl PlatformHttpClient for StubHttpClient {
Ok(PlatformResponse::new(edge_response))
}
+ async fn send_raw_proxy_v1(
+ &self,
+ request: PlatformHttpRequest,
+ policy: RawProxyPolicyV1,
+ ) -> Result> {
+ if request.image_optimizer.is_some() || request.stream_response {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("unsupported option on StubHttpClient raw proxy request"));
+ }
+
+ self.calls
+ .lock()
+ .expect("should lock calls")
+ .push(request.backend_name.clone());
+ self.raw_proxy_policies
+ .lock()
+ .expect("should lock raw proxy policies")
+ .push(policy);
+ self.cache_bypass_flags
+ .lock()
+ .expect("should lock cache bypass flags")
+ .push(request.bypass_cache);
+ self.request_methods
+ .lock()
+ .expect("should lock request methods")
+ .push(request.request.method().to_string());
+ self.request_uris
+ .lock()
+ .expect("should lock request URIs")
+ .push(request.request.uri().to_string());
+ self.request_headers
+ .lock()
+ .expect("should lock request headers")
+ .push(
+ request
+ .request
+ .headers()
+ .iter()
+ .map(|(name, value)| {
+ (
+ name.as_str().to_string(),
+ String::from_utf8_lossy(value.as_bytes()).into_owned(),
+ )
+ })
+ .collect(),
+ );
+
+ let (_, body) = request.request.into_parts();
+ let body = body
+ .into_bytes_bounded(MAX_RECORDED_BODY_BYTES)
+ .await
+ .change_context(PlatformError::HttpClient)?
+ .to_vec();
+ self.request_bodies
+ .lock()
+ .expect("should lock request bodies")
+ .push(body);
+
+ let response = self
+ .raw_proxy_responses
+ .lock()
+ .expect("should lock raw proxy responses")
+ .pop_front()
+ .ok_or_else(|| Report::new(PlatformError::HttpClient))?;
+ if response.body.len() > policy.max_response_bytes {
+ return Err(Report::new(PlatformError::HttpClient)
+ .attach("stub raw proxy body exceeds configured cap"));
+ }
+ Ok(response)
+ }
+
async fn send_async(
&self,
request: PlatformHttpRequest,
diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs
index a39a26430..5643db9e3 100644
--- a/crates/trusted-server-core/src/platform/types.rs
+++ b/crates/trusted-server-core/src/platform/types.rs
@@ -10,6 +10,15 @@ use super::{
PlatformSecretStore,
};
+/// Response-extension marker for routes whose security contract owns the
+/// complete application-header set.
+///
+/// Adapter finalizers must not append geo, deployment, or operator-configured
+/// headers to a marked response. HTTP runtimes may still add transport framing
+/// such as `Content-Length`.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ExactResponseHeadersV1;
+
/// Geographic information extracted from a request.
///
/// Serde derives are required because `GeoInfo` is embedded in
diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs
index 9043b70f0..31b2947ec 100644
--- a/crates/trusted-server-core/src/publisher.rs
+++ b/crates/trusted-server-core/src/publisher.rs
@@ -10,6 +10,9 @@
//! streaming processor treats unknown encodings as identity, so publisher code
//! must gate them out before the body enters the rewrite pipeline.
//!
+//! `BootManifestV1` serialization remains a pure helper in Task 8. This
+//! production pipeline does not emit it until the coordinated Task 19 switch.
+//!
//! **Note on platform coupling:** The handler boundaries use portable HTTP
//! types: [`handle_publisher_request`] and [`stream_publisher_body`] take and
//! return `http::Request`/`http::Response` over `EdgeBody`, and platform I/O is
@@ -18,35 +21,36 @@
//! into any [`Write`] (a `Vec` for buffered routes, a streaming writer for
//! the streaming route). It is not a content-rewriting concern.
-use std::borrow::Cow;
+use std::collections::{BTreeMap, HashSet};
use std::io::Write;
use std::sync::{Arc, Mutex};
use std::time::Duration;
-use brotli::Decompressor;
-use brotli::enc::BrotliEncoderParams;
-use brotli::enc::writer::CompressorWriter;
use cookie::CookieJar;
use edgezero_core::body::Body as EdgeBody;
use error_stack::{Report, ResultExt};
-use flate2::read::ZlibDecoder;
-use flate2::write::{GzEncoder, ZlibEncoder};
use futures::StreamExt as _;
use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header};
use crate::auction::endpoints::{
merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids,
};
-use crate::auction::formats::sanitize_publisher_page_url;
+use crate::auction::formats::{
+ coordinated_cutover_v1::CanonicalBrowserAuctionProjectionV1, sanitize_publisher_page_url,
+};
use crate::auction::orchestrator::{
- AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction,
+ AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, OrchestrationResult,
};
use crate::auction::telemetry::{
AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events,
emit_auction_events_best_effort_lazy,
};
use crate::auction::types::{
- AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo,
+ AdmRenderSourceV1, AuctionContext, AuctionDecisionSetV1, AuctionIdentityGenerator,
+ AuctionRequest, AuctionSlotFailureReason, Bid, BidRenderSourceV1, BrowserAuctionBidV1,
+ BrowserAuctionProjectionV1, BrowserAuctionSlotV1, CacheFetchPolicyV1, CacheRenderSourceV1,
+ DeviceInfo, PublisherInfo, SiteInfo, SlotAuctionDecisionV1, SystemAuctionIdentityGenerator,
+ UserInfo, mint_response_unique_base64url_identity,
};
use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent};
use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT};
@@ -63,8 +67,8 @@ use crate::response_privacy::CDN_CACHE_HEADERS;
use crate::rsc_flight::RscFlightUrlRewriter;
use crate::settings::Settings;
use crate::streaming_processor::{
- BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig,
- STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline,
+ BodyStreamDecoder, BodyStreamEncoder, Compression, PipelineConfig, STREAM_CHUNK_SIZE,
+ StreamProcessor, StreamingPipeline,
};
use crate::streaming_replacer::create_url_replacer;
@@ -196,6 +200,14 @@ fn not_found_response() -> Response {
response
}
+fn tsjs_not_found_response() -> Response {
+ let mut response = not_found_response();
+ response
+ .headers_mut()
+ .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
+ response
+}
+
fn restrict_accept_encoding(req: &mut Request) {
// If the client sent no Accept-Encoding, leave the request unchanged so the
// origin responds without compression. Adding encodings here would cause the
@@ -273,13 +285,7 @@ fn accept_encoding_qvalue(header_value: &str, target: &str) -> Option {
matched_qvalue
}
-/// Unified tsjs static serving: `/static/tsjs=`
-///
-/// Serves two types of bundles:
-/// - **Unified bundle** (`tsjs-unified.min.js`): core + immediate (non-deferred)
-/// integration modules.
-/// - **Deferred module** (`tsjs-{id}.min.js`): a single self-contained IIFE for
-/// modules loaded with `defer` (e.g., prebid).
+/// Exact content-addressed TSJS release transport.
///
/// # Errors
///
@@ -289,48 +295,76 @@ pub fn handle_tsjs_dynamic(
integration_registry: &IntegrationRegistry,
) -> Result, Report> {
const PREFIX: &str = "/static/tsjs=";
- const UNIFIED_FILENAMES: &[&str] = &["tsjs-unified.js", "tsjs-unified.min.js"];
let path = req.uri().path();
- if !path.starts_with(PREFIX) {
- return Ok(not_found_response());
+ if req.method() != Method::GET || !path.starts_with(PREFIX) {
+ return Ok(tsjs_not_found_response());
}
let filename = &path[PREFIX.len()..];
-
- if UNIFIED_FILENAMES.contains(&filename) {
- // Serve core + immediate modules (excludes deferred like prebid)
- let module_ids = integration_registry.js_module_ids_immediate();
+ let Some(requested_hash) = req.uri().query().and_then(|query| query.strip_prefix("v=")) else {
+ return Ok(tsjs_not_found_response());
+ };
+ if !valid_lowercase_sha256(requested_hash) {
+ return Ok(tsjs_not_found_response());
+ }
+
+ if filename == "tsjs-unified.min.js" {
+ let module_ids = integration_registry
+ .tsjs_static_transport_selections(false)
+ .into_iter()
+ .map(|selection| integration_registry.tsjs_critical_module_ids(selection))
+ .find(|ids| trusted_server_js::concatenated_hash(ids) == requested_hash);
+ let Some(module_ids) = module_ids else {
+ return Ok(tsjs_not_found_response());
+ };
let body = trusted_server_js::concatenate_modules(&module_ids);
let mut resp = serve_static_with_etag(&body, req, "application/javascript; charset=utf-8");
- resp.headers_mut()
- .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on"));
+ apply_tsjs_success_headers(&mut resp);
return Ok(resp);
}
if let Some(module_id) = parse_single_module_filename(filename) {
- // Deferred modules and the conditionally injected diagnostics module
- // are served as content-addressed standalone assets. Delivery remains
- // cookie-independent so the static response can stay publicly cached.
- let deferred_ids = integration_registry.js_module_ids_deferred();
- let diagnostics_standalone = module_id
- == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID
- && integration_registry.integration_enabled(module_id);
- if !deferred_ids.contains(&module_id) && !diagnostics_standalone {
- return Ok(not_found_response());
- }
- if let Some(content) = trusted_server_js::module_bundle(module_id) {
+ let render_trace_overlay = crate::trace_cookie::render_trace_overlay_active(req);
+ let module_enabled = integration_registry
+ .tsjs_static_transport_selections(render_trace_overlay)
+ .into_iter()
+ .any(|selection| {
+ integration_registry
+ .tsjs_deferred_module_ids(selection)
+ .contains(&module_id)
+ });
+ if module_enabled
+ && trusted_server_js::single_module_hash(module_id).as_deref() == Some(requested_hash)
+ && let Some(content) = trusted_server_js::module_bundle(module_id)
+ {
let mut resp =
serve_static_with_etag(content, req, "application/javascript; charset=utf-8");
- resp.headers_mut()
- .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on"));
+ apply_tsjs_success_headers(&mut resp);
return Ok(resp);
}
}
- Ok(not_found_response())
+ Ok(tsjs_not_found_response())
+}
+
+fn valid_lowercase_sha256(value: &str) -> bool {
+ value.len() == 64
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+fn apply_tsjs_success_headers(response: &mut Response) {
+ response
+ .headers_mut()
+ .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on"));
+ response.headers_mut().insert(
+ header::X_CONTENT_TYPE_OPTIONS,
+ HeaderValue::from_static("nosniff"),
+ );
}
-/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`.
+/// Extract a catalogued deferred module ID from its only admitted filename.
///
/// Returns `Some(&'static str)` if the filename matches a known JS module ID,
/// `None` otherwise. The caller must additionally verify that the module is
@@ -339,11 +373,15 @@ pub fn handle_tsjs_dynamic(
fn parse_single_module_filename(filename: &str) -> Option<&'static str> {
let stem = filename
.strip_prefix("tsjs-")
- .and_then(|s| s.strip_suffix(".min.js").or_else(|| s.strip_suffix(".js")))?;
+ .and_then(|s| s.strip_suffix(".min.js"))?;
- trusted_server_js::all_module_ids()
+ trusted_server_js::all_integration_metadata()
.into_iter()
- .find(|&id| id == stem)
+ .find(|metadata| {
+ metadata.id == stem
+ && metadata.phase == Some(trusted_server_js::TsjsModulePhase::Deferred)
+ })
+ .map(|metadata| metadata.id)
}
/// Parameters for processing response streaming.
@@ -361,6 +399,7 @@ struct ProcessResponseParams<'a> {
suppress_datadome_client_side_tag: bool,
gpt_diagnostics:
Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>,
+ render_trace_overlay: bool,
}
struct PublisherBodyProcessor {
@@ -387,6 +426,7 @@ impl PublisherBodyProcessor {
ad_bids_state: Arc::clone(¶ms.ad_bids_state),
suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag,
gpt_diagnostics: params.gpt_diagnostics.clone(),
+ render_trace_overlay: params.render_trace_overlay,
})?)
} else if is_rsc_flight {
Box::new(RscFlightUrlRewriter::new(
@@ -465,6 +505,7 @@ fn process_response_streaming(
ad_bids_state: params.ad_bids_state.clone(),
suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag,
gpt_diagnostics: params.gpt_diagnostics.cloned(),
+ render_trace_overlay: params.render_trace_overlay,
})?;
StreamingPipeline::new(config, processor)
.with_max_pending_decoded_bytes(max_pending_decoded_bytes)
@@ -681,253 +722,6 @@ impl Drop for DispatchedAuctionGuard {
}
}
-/// Mutable auction-hold state threaded through the streaming hold pipeline.
-struct AuctionHoldState {
- hold: Option,
- dispatched: DispatchedAuctionGuard,
- telemetry: AuctionTelemetryCarry,
-}
-
-impl AuctionHoldState {
- fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self {
- Self {
- hold: Some(BodyCloseHoldBuffer::new()),
- dispatched,
- telemetry,
- }
- }
-}
-
-/// Abandon the in-flight auction (if still pending) with the given telemetry
-/// reason. No-op once the auction has been collected or already abandoned.
-async fn abandon_hold_auction(
- state: &mut AuctionHoldState,
- services: &RuntimeServices,
- reason: &'static str,
-) {
- if let Some(dispatched) = state.dispatched.take() {
- emit_abandoned_auction(
- services,
- state.telemetry.observation.take(),
- dispatched,
- reason,
- )
- .await;
- // Abandonment with telemetry is a terminal result, so the drop warning
- // is no longer warranted. (A drop *during* the emit above still fires
- // it, since the guard stays armed until here.)
- state.dispatched.disarm();
- }
-}
-
-/// Output of a single close-body hold step, split at the auction-collection
-/// barrier.
-///
-/// `ready` is the prefix the caller must emit *before* collecting the auction,
-/// so a small page whose `