From f718059546ec586e8be3ba56a7a09f665dc8979e Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 3 Mar 2026 15:10:00 -0500 Subject: [PATCH 01/13] Align signature verification with Trusted Server v1.1 --- crates/mocktioneer-core/src/render.rs | 9 + crates/mocktioneer-core/src/verification.rs | 211 +++++++++++++++--- .../static/templates/creative.html.hbs | 34 +-- docs/api/openrtb-auction.md | 28 ++- 4 files changed, 236 insertions(+), 46 deletions(-) diff --git a/crates/mocktioneer-core/src/render.rs b/crates/mocktioneer-core/src/render.rs index 02e1673..b01f9f3 100644 --- a/crates/mocktioneer-core/src/render.rs +++ b/crates/mocktioneer-core/src/render.rs @@ -334,4 +334,13 @@ mod tests { assert!(adm.contains("\"seat\": \"mocktioneer\"")); assert!(adm.contains("\"price\": 1.23")); } + + #[test] + fn test_creative_html_always_shows_debug_badge() { + let html = creative_html(728, 90, true, false, "host.test"); + + assert!(html.contains("var sig = validSig[sigParam] ? sigParam : \"not_present\";")); + assert!(html.contains("badge.style.display = \"block\";")); + assert!(html.contains("No signature present")); + } } diff --git a/crates/mocktioneer-core/src/verification.rs b/crates/mocktioneer-core/src/verification.rs index f4819b3..1d28fd3 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -5,12 +5,13 @@ use edgezero_core::context::RequestContext; use edgezero_core::http::{Method, StatusCode, Uri}; use edgezero_core::proxy::ProxyRequest; use futures_util::StreamExt; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; const JWKS_CACHE_TTL: Duration = Duration::from_secs(10 * 60); +const SIGNING_VERSION: &str = "1.1"; #[derive(Debug, Clone, Deserialize)] struct TrustedServerResponse { @@ -33,6 +34,16 @@ struct JwksCache { fetched_at: Instant, } +#[derive(Serialize)] +struct SigningPayload<'a> { + version: &'a str, + kid: &'a str, + host: &'a str, + scheme: &'a str, + id: &'a str, + ts: u64, +} + static JWKS_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -110,20 +121,17 @@ async fn get_cached_jwks( .map_err(|_| VerificationError::HttpError("Cache lock poisoned".to_string()))?; if let Some(cached) = cache.get(&cache_key) { - if cached.fetched_at.elapsed() < JWKS_CACHE_TTL { - log::debug!( - "JWKS cache hit for {} (age: {:?})", - cache_key, - cached.fetched_at.elapsed() - ); + let cache_age = cached.fetched_at.elapsed(); + if cache_age < JWKS_CACHE_TTL { + log::debug!("JWKS cache hit for {} (age: {:?})", cache_key, cache_age); return Ok(cached.jwks.clone()); - } else { - log::debug!( - "JWKS cache expired for {} (age: {:?})", - cache_key, - cached.fetched_at.elapsed() - ); } + + log::debug!( + "JWKS cache expired for {} (age: {:?})", + cache_key, + cache_age + ); } else { log::debug!("JWKS cache empty for {} (first fetch)", cache_key); } @@ -200,6 +208,57 @@ fn verify_ed25519_signature( Ok(()) } +fn build_signing_payload( + request_id: &str, + key_id: &str, + request_host: &str, + request_scheme: &str, + timestamp: u64, + version: &str, +) -> Result { + if version != SIGNING_VERSION { + return Err(VerificationError::InvalidSignature(format!( + "Unsupported ext.trusted_server.version '{}'; expected '{}'", + version, SIGNING_VERSION + ))); + } + + let payload = SigningPayload { + version, + kid: key_id, + host: request_host, + scheme: request_scheme, + id: request_id, + ts: timestamp, + }; + + serde_json::to_string(&payload).map_err(|e| { + VerificationError::InvalidSignature(format!("Failed to serialize signing payload: {}", e)) + }) +} + +fn required_ext_str<'a>( + ext_obj: &'a serde_json::Value, + field: &str, + missing_error: VerificationError, +) -> Result<&'a str, VerificationError> { + ext_obj + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or(missing_error) +} + +fn required_ext_u64( + ext_obj: &serde_json::Value, + field: &str, + missing_error: VerificationError, +) -> Result { + ext_obj + .get(field) + .and_then(serde_json::Value::as_u64) + .ok_or(missing_error) +} + pub async fn verify_request_id_signature( ctx: &RequestContext, request_id: &str, @@ -210,27 +269,65 @@ pub async fn verify_request_id_signature( VerificationError::InvalidSignature("Missing ext.trusted_server".to_string()) })?; - let signature = ext_obj - .get("signature") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - VerificationError::InvalidSignature("Missing ext.trusted_server.signature".to_string()) - })?; - - let key_id = ext_obj.get("kid").and_then(|v| v.as_str()).ok_or_else(|| { - VerificationError::KeyNotFound("Missing ext.trusted_server.kid".to_string()) - })?; + let signature = required_ext_str( + ext_obj, + "signature", + VerificationError::InvalidSignature("Missing ext.trusted_server.signature".to_string()), + )?; + + let key_id = required_ext_str( + ext_obj, + "kid", + VerificationError::KeyNotFound("Missing ext.trusted_server.kid".to_string()), + )?; + + let version = required_ext_str( + ext_obj, + "version", + VerificationError::InvalidSignature("Missing ext.trusted_server.version".to_string()), + )?; + + let request_host = required_ext_str( + ext_obj, + "request_host", + VerificationError::InvalidSignature("Missing ext.trusted_server.request_host".to_string()), + )?; + + let request_scheme = required_ext_str( + ext_obj, + "request_scheme", + VerificationError::InvalidSignature( + "Missing ext.trusted_server.request_scheme".to_string(), + ), + )?; + + let timestamp = required_ext_u64( + ext_obj, + "ts", + VerificationError::InvalidSignature("Missing ext.trusted_server.ts".to_string()), + )?; + + let payload = build_signing_payload( + request_id, + key_id, + request_host, + request_scheme, + timestamp, + version, + )?; log::info!( - "Signature verification requested: id={}, kid={}, domain={:?}", + "Signature verification requested: id={}, kid={}, domain={:?}, version={}, ts={}", request_id, key_id, - domain + domain, + version, + timestamp ); let jwks = get_cached_jwks(ctx, domain).await?; let public_key = find_public_key(&jwks, key_id)?; - verify_ed25519_signature(public_key, signature, request_id)?; + verify_ed25519_signature(public_key, signature, &payload)?; Ok(key_id.to_string()) } @@ -338,6 +435,68 @@ mod tests { )); } + #[test] + fn verify_missing_version_field() { + let request_id = "test-id"; + let ext = serde_json::json!({ + "trusted_server": { + "signature": "test-sig", + "kid": "test-key", + "request_host": "example.com", + "request_scheme": "https", + "ts": 1706900000 + } + }); + + let ctx = create_test_context(); + + let result = block_on(verify_request_id_signature( + &ctx, + request_id, + Some(&ext), + "example.com", + )); + assert!(matches!( + result.unwrap_err(), + VerificationError::InvalidSignature(_) + )); + } + + #[test] + fn build_signing_payload_uses_v11_shape() { + let payload = build_signing_payload( + "req-123", + "kid-abc", + "publisher.example", + "https", + 1706900000, + "1.1", + ) + .expect("payload"); + + assert_eq!( + payload, + "{\"version\":\"1.1\",\"kid\":\"kid-abc\",\"host\":\"publisher.example\",\"scheme\":\"https\",\"id\":\"req-123\",\"ts\":1706900000}" + ); + } + + #[test] + fn build_signing_payload_rejects_unknown_version() { + let result = build_signing_payload( + "req-123", + "kid-abc", + "publisher.example", + "https", + 1706900000, + "1.0", + ); + + assert!(matches!( + result.unwrap_err(), + VerificationError::InvalidSignature(_) + )); + } + #[test] fn find_public_key_found() { let jwks = JwksResponse { diff --git a/crates/mocktioneer-core/static/templates/creative.html.hbs b/crates/mocktioneer-core/static/templates/creative.html.hbs index c50ede0..e7afcd3 100644 --- a/crates/mocktioneer-core/static/templates/creative.html.hbs +++ b/crates/mocktioneer-core/static/templates/creative.html.hbs @@ -76,25 +76,29 @@ (function () { var p = new URLSearchParams(location.search), c = p.get("crid") || "", - sig = p.get("sig") || ""; + sigParam = p.get("sig") || ""; + var validSig = { + verified: true, + failed: true, + not_present: true + }; + var sig = validSig[sigParam] ? sigParam : "not_present"; // Wire click-through with creative metadata so the landing can echo it document.getElementById("clk").href = "//{{HOST}}/click?crid=" + encodeURIComponent(c) + "&w={{W}}&h={{H}}"; - // Render signature verification badge if sig param is present - if (sig) { - var badge = document.getElementById("sig-badge"); - var badges = { - verified: { bg: "rgba(0,128,0,.85)", text: "\u2714\uFE0E Request signature verified" }, - failed: { bg: "rgba(200,0,0,.85)", text: "\u274C Request signature not verified" }, - not_present: { bg: "rgba(128,128,128,.75)", text: "\u2014 No signature present" } - }; - var info = badges[sig]; - if (info && badge) { - badge.style.background = info.bg; - badge.textContent = info.text; - badge.style.display = "block"; - } + // Always render debug badge with signature status and creative details + var badge = document.getElementById("sig-badge"); + var badges = { + verified: { bg: "rgba(0,128,0,.85)", text: "\u2714\uFE0E Request signature verified" }, + failed: { bg: "rgba(200,0,0,.85)", text: "\u274C Request signature not verified" }, + not_present: { bg: "rgba(128,128,128,.75)", text: "\u2014 No signature present" } + }; + var info = badges[sig] || badges.not_present; + if (badge) { + badge.style.background = info.bg; + badge.textContent = info.text; + badge.style.display = "block"; } })(); diff --git a/docs/api/openrtb-auction.md b/docs/api/openrtb-auction.md index 211d82e..1e22e31 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -57,8 +57,12 @@ Content-Type: application/json ], "ext": { "trusted_server": { + "version": "1.1", "signature": "base64-encoded-signature", - "kid": "key-id" + "kid": "key-id", + "request_host": "publisher.example", + "request_scheme": "https", + "ts": 1706900000000 } } } @@ -76,8 +80,12 @@ Content-Type: application/json | `imp[].banner.h` | integer | No | Height in pixels | | `imp[].banner.format` | array | No | Array of size objects | | `imp[].ext.mocktioneer.bid` | float | No | Override bid price | -| `ext.trusted_server.signature` | string | No | Signature for request ID verification | -| `ext.trusted_server.kid` | string | No | Key ID for signature verification | +| `ext.trusted_server.version` | string | No | Signing protocol version (`1.1`) | +| `ext.trusted_server.signature` | string | No | Signature for canonical payload | +| `ext.trusted_server.kid` | string | No | Key ID used for signature | +| `ext.trusted_server.request_host` | string | No | Host included in signed payload | +| `ext.trusted_server.request_scheme` | string | No | Scheme included in signed payload | +| `ext.trusted_server.ts` | integer | No | Unix timestamp (milliseconds) | | `site` | object | No | Site information | | `site.domain` | string | No | Domain for signature verification | @@ -249,7 +257,17 @@ curl -X POST http://127.0.0.1:8787/openrtb2/auction \ Mocktioneer supports optional request signature verification. When `site.domain` is present, it attempts to verify the request signature using: -- `ext.trusted_server.signature` - Base64-encoded signature +- `ext.trusted_server.version` - Signing protocol version (`1.1`) +- `ext.trusted_server.signature` - Base64 URL-safe Ed25519 signature - `ext.trusted_server.kid` - Key ID for signature verification +- `ext.trusted_server.request_host` - Host bound into the signed payload +- `ext.trusted_server.request_scheme` - Scheme bound into the signed payload +- `ext.trusted_server.ts` - Unix timestamp in milliseconds -The JWKS is fetched from `http://{site.domain}/.well-known/trusted-server.json`. Verification failures are logged but don't reject the request. +The signed payload is canonical JSON: + +```json +{"version":"1.1","kid":"...","host":"...","scheme":"https","id":"...","ts":1706900000000} +``` + +The JWKS is fetched from `https://{site.domain}/.well-known/trusted-server.json`. Verification failures are logged but don't reject the request. From 49f61f4ac6a2d3c0519dba4ba3c689dfd284ba7c Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 3 Mar 2026 15:15:20 -0500 Subject: [PATCH 02/13] Use fixed CPM bids and align OpenRTB docs #81 --- crates/mocktioneer-core/src/auction.rs | 62 +++++++------------------- docs/api/openrtb-auction.md | 34 +++----------- 2 files changed, 22 insertions(+), 74 deletions(-) diff --git a/crates/mocktioneer-core/src/auction.rs b/crates/mocktioneer-core/src/auction.rs index 50d9b4e..eb0e508 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -4,7 +4,6 @@ use crate::openrtb::{ }; use crate::render::{iframe_html, CreativeMetadata, SignatureStatus}; use phf::phf_map; -use serde_json::json; use uuid::Uuid; // ============================================================================ @@ -14,6 +13,9 @@ use uuid::Uuid; /// Default CPM for non-standard sizes (base price before area adjustment). pub const DEFAULT_CPM: f64 = 1.50; +/// Fixed CPM used for all Mocktioneer-generated bids. +pub const FIXED_BID_CPM: f64 = 0.01; + /// Maximum area-based bonus added to DEFAULT_CPM for non-standard sizes. /// Final CPM = DEFAULT_CPM + min(area/100000, MAX_AREA_BONUS) pub const MAX_AREA_BONUS: f64 = 3.00; @@ -114,8 +116,7 @@ pub fn standard_or_default((w, h): (i64, i64)) -> (i64, i64) { /// Build an OpenRTB bid response for the given request. /// /// - Enforces standard ad sizes (non-standard sizes default to 300x250) -/// - Uses size-based CPM pricing ($1.70 - $4.20 depending on size) -/// - Price can be overridden via `imp.ext.mocktioneer.bid` +/// - Uses a fixed CPM price ($0.01) /// - Embeds signature verification status, the original request, and a preview /// of the response as HTML comments in each creative /// - The signature badge is rendered inside the creative via the `sig` query param @@ -131,16 +132,7 @@ pub fn build_openrtb_response( let bid_id = new_id(); let crid = format!("mocktioneer-{}", imp.id); - // Extract custom bid from imp.ext.mocktioneer.bid if present - let custom_bid = imp - .ext - .as_ref() - .and_then(|e| e.mocktioneer.as_ref()) - .and_then(|m| m.bid); - - // Use custom bid if provided, otherwise use size-based CPM - let price = custom_bid.unwrap_or_else(|| get_cpm(w, h)); - let bid_ext = custom_bid.map(|b| json!({"mocktioneer": {"bid": b}})); + let price = FIXED_BID_CPM; bids.push(OpenrtbBid { id: bid_id, @@ -152,7 +144,7 @@ pub fn build_openrtb_response( h: Some(h), mtype: Some(MediaType::Banner), adomain: Some(vec!["example.com".to_string()]), - ext: bid_ext, + ext: None, ..Default::default() }); } @@ -189,22 +181,10 @@ pub fn build_openrtb_response( let final_bids: Vec = bids .into_iter() .map(|mut bid| { - let bid_for_iframe = if bid.ext.is_some() { - Some(bid.price) - } else { - None - }; let crid = bid.crid.as_deref().unwrap_or("unknown"); let w = bid.w.unwrap_or(300); let h = bid.h.unwrap_or(250); - bid.adm = Some(iframe_html( - base_host, - crid, - w, - h, - bid_for_iframe, - &metadata, - )); + bid.adm = Some(iframe_html(base_host, crid, w, h, None, &metadata)); bid }) .collect(); @@ -252,9 +232,7 @@ pub fn decode_aps_price(encoded: &str) -> Option { /// Build APS TAM response from an APS bid request matching real Amazon API format. /// /// This function generates mock bids for all slots with standard sizes: -/// - Variable bid prices based on ad size (via `get_cpm()`) -/// - Ranges from $1.70 - $4.20 CPM for standard sizes -/// - Example: 300x250 = $2.50, 970x250 = $4.20, 320x50 = $1.80 +/// - Fixed bid price of $0.01 CPM /// - 100% fill rate for standard sizes /// - Returns contextual format matching real Amazon APS API /// - No creative HTML (APS doesn't return adm field) @@ -279,7 +257,7 @@ pub fn build_aps_response(req: &ApsBidRequest, base_host: &str) -> ApsBidRespons }) .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); - let Some((w, h, price)) = best_size else { + let Some((w, h, _)) = best_size else { // No standard sizes found, skip this slot log::debug!( "APS: Skipping slot '{}' - no standard sizes in {:?}", @@ -289,7 +267,8 @@ pub fn build_aps_response(req: &ApsBidRequest, base_host: &str) -> ApsBidRespons continue; }; - // Generate bid components (price already calculated in best_size selection) + // Generate bid components using fixed CPM pricing + let price = FIXED_BID_CPM; let impression_id = new_id(); let crid = format!("{}-{}", new_id(), "mocktioneer"); let size_str = format!("{}x{}", w, h); @@ -511,18 +490,11 @@ mod tests { }; let resp = build_openrtb_response(&req, "host.test", test_signature()); let bid = &resp.seatbid[0].bid[0]; - assert_eq!(bid.price, 2.5); - let ext_bid = bid - .ext - .as_ref() - .and_then(|e| e.get("mocktioneer")) - .and_then(|m| m.get("bid")) - .and_then(|v| v.as_f64()) - .unwrap(); - assert_eq!(ext_bid, 2.5); - // Iframe should include bid=2.50 parameter + assert_eq!(bid.price, FIXED_BID_CPM); + assert!(bid.ext.is_none()); + // Iframe should not include request-provided bid override let adm = bid.adm.as_ref().unwrap(); - assert!(adm.contains("bid=2.50")); + assert!(!adm.contains("bid=2.50")); } // ======================================================================== @@ -601,7 +573,7 @@ mod tests { pub_id: "test".to_string(), slots: vec![ApsSlot { slot_id: "slot1".to_string(), - sizes: vec![[300, 250]], // CPM is $2.50 + sizes: vec![[300, 250]], // CPM is fixed at $0.01 slot_name: None, }], page_url: None, @@ -613,7 +585,7 @@ mod tests { // Use decode_aps_price to verify the encoded price let price = decode_aps_price(slot.amznbid.as_ref().unwrap()).unwrap(); - assert_eq!(price, 2.5); + assert_eq!(price, FIXED_BID_CPM); } #[test] diff --git a/docs/api/openrtb-auction.md b/docs/api/openrtb-auction.md index 1e22e31..662a16a 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -79,7 +79,6 @@ Content-Type: application/json | `imp[].banner.w` | integer | No | Width in pixels | | `imp[].banner.h` | integer | No | Height in pixels | | `imp[].banner.format` | array | No | Array of size objects | -| `imp[].ext.mocktioneer.bid` | float | No | Override bid price | | `ext.trusted_server.version` | string | No | Signing protocol version (`1.1`) | | `ext.trusted_server.signature` | string | No | Signature for canonical payload | | `ext.trusted_server.kid` | string | No | Key ID used for signature | @@ -109,8 +108,8 @@ Size is determined in this order: { "id": "019abc123", "impid": "imp-1", - "price": 2.5, - "adm": "", + "price": 0.01, + "adm": "", "adomain": ["example.com"], "crid": "mocktioneer-imp-1", "w": 300, @@ -143,34 +142,11 @@ Size is determined in this order: | `seatbid[].bid[].mtype` | integer | Media type (1 = banner) | | `cur` | string | Currency (USD) | -## Price Override +## Pricing -Override the bid price using the `ext.mocktioneer.bid` field: +Mocktioneer returns a fixed bid price of `$0.01` CPM for auction responses. -```json -{ - "id": "test", - "imp": [ - { - "id": "1", - "banner": { "w": 300, "h": 250 }, - "ext": { - "mocktioneer": { - "bid": 5.0 - } - } - } - ] -} -``` - -The creative will display this bid amount. - -## Default Pricing - -Without a price override, Mocktioneer uses fixed CPM prices based on ad size. Prices range from $1.70 (300x50) to $4.20 (970x250). Non-standard sizes use an area-based fallback formula. - -See the [complete pricing table](/api/#supported-sizes) for all supported sizes and their CPM values. +If `imp[].ext.mocktioneer.bid` is present, it is ignored. ## Examples From b60a976db7d87b12f269657c1795daf35353f93f Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 6 Mar 2026 11:51:47 -0500 Subject: [PATCH 03/13] Address PR #80 review: harden verification trust model and align docs with fixed pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cross-check ext.trusted_server host/scheme against server-observed values - Enforce timestamp freshness window (±5 min) to prevent replay attacks - Add field-order warning comment on SigningPayload struct - Fix ts unit mismatch: align tests with docs (milliseconds) - Add positive-path Ed25519 round-trip test with deterministic keypair - Remove debug log leftover in fetch_jwks - Simplify APS size selection to area-based ranking (replaces CPM) - Remove CPM from /sizes endpoint response - Rename test to test_ext_bid_override_is_ignored - Docs sweep: remove bid override references across all doc pages --- crates/mocktioneer-core/src/auction.rs | 16 +- crates/mocktioneer-core/src/routes.rs | 22 +- crates/mocktioneer-core/src/verification.rs | 227 +++++++++++++++++- .../mocktioneer-core/tests/aps_endpoints.rs | 6 +- docs/api/aps-bid.md | 10 +- docs/api/index.md | 70 +++--- docs/api/openrtb-auction.md | 20 -- docs/index.md | 2 +- docs/integrations/prebid-server.md | 59 ++--- docs/integrations/prebidjs.md | 36 +-- 10 files changed, 310 insertions(+), 158 deletions(-) diff --git a/crates/mocktioneer-core/src/auction.rs b/crates/mocktioneer-core/src/auction.rs index eb0e508..abb8ce4 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -241,7 +241,7 @@ pub fn build_aps_response(req: &ApsBidRequest, base_host: &str) -> ApsBidRespons let mut slots: Vec = Vec::new(); for slot in req.slots.iter() { - // Find the standard size with the highest CPM from all sizes in the slot + // Find the standard size with the largest area from all sizes in the slot let best_size = slot .sizes .iter() @@ -249,13 +249,13 @@ pub fn build_aps_response(req: &ApsBidRequest, base_host: &str) -> ApsBidRespons let w_i64 = w as i64; let h_i64 = h as i64; if is_standard_size(w_i64, h_i64) { - let price = get_cpm(w_i64, h_i64); - Some((w, h, price)) + let area = w_i64 * h_i64; + Some((w, h, area)) } else { None } }) - .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + .max_by_key(|&(_, _, area)| area); let Some((w, h, _)) = best_size else { // No standard sizes found, skip this slot @@ -471,7 +471,7 @@ mod tests { } #[test] - fn test_price_from_ext_and_iframe_bid_param() { + fn test_ext_bid_override_is_ignored() { let req = OpenRTBRequest { id: "r4".to_string(), imp: vec![OpenrtbImp { @@ -548,12 +548,12 @@ mod tests { } #[test] - fn test_build_aps_response_selects_highest_cpm() { + fn test_build_aps_response_selects_largest_area() { let req = ApsBidRequest { pub_id: "test".to_string(), slots: vec![ApsSlot { slot_id: "slot1".to_string(), - sizes: vec![[300, 250], [970, 250]], // 970x250 has higher CPM ($4.20 vs $2.50) + sizes: vec![[300, 250], [970, 250]], // 970x250 has larger area (242500 vs 75000) slot_name: None, }], page_url: None, @@ -564,7 +564,7 @@ mod tests { assert_eq!(resp.contextual.slots.len(), 1); let slot = &resp.contextual.slots[0]; - assert_eq!(slot.size, "970x250"); // Should pick higher CPM size + assert_eq!(slot.size, "970x250"); // Should pick largest area } #[test] diff --git a/crates/mocktioneer-core/src/routes.rs b/crates/mocktioneer-core/src/routes.rs index 24d4d0b..51a72e7 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -239,6 +239,14 @@ pub async fn handle_openrtb_auction( ForwardedHost(host): ForwardedHost, ValidatedJson(req): ValidatedJson, ) -> Result { + // Determine server-observed scheme from X-Forwarded-Proto header, default to "https" + let scheme = ctx + .request() + .headers() + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("https"); + // Capture signature verification status for metadata let signature_status = if let Some(domain) = req.site.as_ref().and_then(|s| s.domain.as_deref()) { @@ -247,6 +255,8 @@ pub async fn handle_openrtb_auction( &req.id, req.ext.as_ref(), domain, + &host, + scheme, ) .await { @@ -498,22 +508,19 @@ pub async fn handle_click(ValidatedQuery(params): ValidatedQuery Response { - use crate::auction::get_cpm; - let sizes: Vec = standard_sizes() .map(|(w, h)| { serde_json::json!({ "width": w, - "height": h, - "cpm": get_cpm(w, h) + "height": h }) }) .collect(); @@ -1002,6 +1009,7 @@ mod tests { let first = &sizes[0]; assert!(first["width"].is_i64()); assert!(first["height"].is_i64()); - assert!(first["cpm"].is_f64()); + // CPM is no longer included — bid price is fixed at FIXED_BID_CPM + assert!(first.get("cpm").is_none()); } } diff --git a/crates/mocktioneer-core/src/verification.rs b/crates/mocktioneer-core/src/verification.rs index 1d28fd3..0cd9b6a 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -13,6 +13,9 @@ use std::time::{Duration, Instant}; const JWKS_CACHE_TTL: Duration = Duration::from_secs(10 * 60); const SIGNING_VERSION: &str = "1.1"; +/// Maximum allowed clock skew for timestamp freshness check (5 minutes in milliseconds). +const TS_FRESHNESS_WINDOW_MS: u64 = 5 * 60 * 1000; + #[derive(Debug, Clone, Deserialize)] struct TrustedServerResponse { jwks: JwksResponse, @@ -34,6 +37,9 @@ struct JwksCache { fetched_at: Instant, } +// IMPORTANT: Field order defines the canonical signing payload. +// `serde_json::to_string` serializes struct fields in declaration order. +// Reordering fields will silently break signature verification. #[derive(Serialize)] struct SigningPayload<'a> { version: &'a str, @@ -70,7 +76,6 @@ async fn fetch_jwks(ctx: &RequestContext, domain: &str) -> Result() .map_err(|e| VerificationError::HttpError(format!("Invalid JWKS URL: {}", e)))?; - log::info!("URI: {}", uri); let proxy_request = ProxyRequest::new(Method::GET, uri); let proxy_handle = ctx .proxy_handle() @@ -259,11 +264,34 @@ fn required_ext_u64( .ok_or(missing_error) } +fn current_time_ms() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .map_err(|_| VerificationError::InvalidSignature("System clock error".to_string())) +} + +fn check_timestamp_freshness(timestamp_ms: u64) -> Result<(), VerificationError> { + let now_ms = current_time_ms()?; + let diff = now_ms.abs_diff(timestamp_ms); + + if diff > TS_FRESHNESS_WINDOW_MS { + return Err(VerificationError::InvalidSignature(format!( + "ext.trusted_server.ts is stale: {}ms drift exceeds {}ms window", + diff, TS_FRESHNESS_WINDOW_MS + ))); + } + + Ok(()) +} + pub async fn verify_request_id_signature( ctx: &RequestContext, request_id: &str, ext: Option<&serde_json::Value>, domain: &str, + trusted_host: &str, + trusted_scheme: &str, ) -> Result { let ext_obj = ext.and_then(|e| e.get("trusted_server")).ok_or_else(|| { VerificationError::InvalidSignature("Missing ext.trusted_server".to_string()) @@ -307,6 +335,24 @@ pub async fn verify_request_id_signature( VerificationError::InvalidSignature("Missing ext.trusted_server.ts".to_string()), )?; + // Cross-check ext fields against server-observed values + if request_host != trusted_host { + return Err(VerificationError::InvalidSignature(format!( + "ext.trusted_server.request_host '{}' does not match server-observed host '{}'", + request_host, trusted_host + ))); + } + + if request_scheme != trusted_scheme { + return Err(VerificationError::InvalidSignature(format!( + "ext.trusted_server.request_scheme '{}' does not match server-observed scheme '{}'", + request_scheme, trusted_scheme + ))); + } + + // Enforce timestamp freshness to prevent replay attacks + check_timestamp_freshness(timestamp)?; + let payload = build_signing_payload( request_id, key_id, @@ -366,6 +412,8 @@ mod tests { request_id, Some(&ext), "example.com", + "example.com", + "https", )); assert!(matches!( result.unwrap_err(), @@ -389,6 +437,8 @@ mod tests { request_id, Some(&ext), "example.com", + "example.com", + "https", )); assert!(matches!( result.unwrap_err(), @@ -410,6 +460,8 @@ mod tests { request_id, Some(&ext), "example.com", + "example.com", + "https", )); assert!(matches!( result.unwrap_err(), @@ -428,6 +480,8 @@ mod tests { request_id, None, "example.com", + "example.com", + "https", )); assert!(matches!( result.unwrap_err(), @@ -438,13 +492,14 @@ mod tests { #[test] fn verify_missing_version_field() { let request_id = "test-id"; + let now_ms = current_time_ms().unwrap(); let ext = serde_json::json!({ "trusted_server": { "signature": "test-sig", "kid": "test-key", "request_host": "example.com", "request_scheme": "https", - "ts": 1706900000 + "ts": now_ms } }); @@ -455,6 +510,8 @@ mod tests { request_id, Some(&ext), "example.com", + "example.com", + "https", )); assert!(matches!( result.unwrap_err(), @@ -469,14 +526,14 @@ mod tests { "kid-abc", "publisher.example", "https", - 1706900000, + 1706900000000, "1.1", ) .expect("payload"); assert_eq!( payload, - "{\"version\":\"1.1\",\"kid\":\"kid-abc\",\"host\":\"publisher.example\",\"scheme\":\"https\",\"id\":\"req-123\",\"ts\":1706900000}" + "{\"version\":\"1.1\",\"kid\":\"kid-abc\",\"host\":\"publisher.example\",\"scheme\":\"https\",\"id\":\"req-123\",\"ts\":1706900000000}" ); } @@ -487,7 +544,7 @@ mod tests { "kid-abc", "publisher.example", "https", - 1706900000, + 1706900000000, "1.0", ); @@ -539,4 +596,164 @@ mod tests { VerificationError::InvalidSignature(_) )); } + + #[test] + fn verify_host_mismatch_rejected() { + let now_ms = current_time_ms().unwrap(); + let ext = serde_json::json!({ + "trusted_server": { + "signature": "test-sig", + "kid": "test-key", + "version": "1.1", + "request_host": "attacker.example", + "request_scheme": "https", + "ts": now_ms + } + }); + + let ctx = create_test_context(); + let result = block_on(verify_request_id_signature( + &ctx, + "test-id", + Some(&ext), + "example.com", + "example.com", + "https", + )); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not match server-observed host")); + } + + #[test] + fn verify_scheme_mismatch_rejected() { + let now_ms = current_time_ms().unwrap(); + let ext = serde_json::json!({ + "trusted_server": { + "signature": "test-sig", + "kid": "test-key", + "version": "1.1", + "request_host": "example.com", + "request_scheme": "http", + "ts": now_ms + } + }); + + let ctx = create_test_context(); + let result = block_on(verify_request_id_signature( + &ctx, + "test-id", + Some(&ext), + "example.com", + "example.com", + "https", + )); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not match server-observed scheme")); + } + + #[test] + fn verify_stale_timestamp_rejected() { + // Timestamp 10 minutes in the past (exceeds 5-minute window) + let stale_ts = current_time_ms().unwrap() - 10 * 60 * 1000; + let ext = serde_json::json!({ + "trusted_server": { + "signature": "test-sig", + "kid": "test-key", + "version": "1.1", + "request_host": "example.com", + "request_scheme": "https", + "ts": stale_ts + } + }); + + let ctx = create_test_context(); + let result = block_on(verify_request_id_signature( + &ctx, + "test-id", + Some(&ext), + "example.com", + "example.com", + "https", + )); + let err = result.unwrap_err().to_string(); + assert!(err.contains("stale")); + } + + #[test] + fn verify_future_timestamp_rejected() { + // Timestamp 10 minutes in the future (exceeds 5-minute window) + let future_ts = current_time_ms().unwrap() + 10 * 60 * 1000; + let ext = serde_json::json!({ + "trusted_server": { + "signature": "test-sig", + "kid": "test-key", + "version": "1.1", + "request_host": "example.com", + "request_scheme": "https", + "ts": future_ts + } + }); + + let ctx = create_test_context(); + let result = block_on(verify_request_id_signature( + &ctx, + "test-id", + Some(&ext), + "example.com", + "example.com", + "https", + )); + let err = result.unwrap_err().to_string(); + assert!(err.contains("stale")); + } + + #[test] + fn check_timestamp_freshness_within_window() { + let now_ms = current_time_ms().unwrap(); + // Current time should pass + assert!(check_timestamp_freshness(now_ms).is_ok()); + // 1 minute ago should pass + assert!(check_timestamp_freshness(now_ms - 60_000).is_ok()); + // 1 minute in the future should pass + assert!(check_timestamp_freshness(now_ms + 60_000).is_ok()); + } + + #[test] + fn verify_ed25519_roundtrip_with_known_keypair() { + use ed25519_dalek::SigningKey; + + // Deterministic seed for reproducible test + let seed: [u8; 32] = [42u8; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + + // Encode keys as base64url (no padding) + let public_key_b64 = URL_SAFE_NO_PAD.encode(verifying_key.as_bytes()); + + // Build a canonical signing payload + let payload = build_signing_payload( + "req-roundtrip", + "kid-test", + "publisher.example", + "https", + 1706900000000, + "1.1", + ) + .expect("payload"); + + // Sign the payload + use ed25519_dalek::Signer; + let signature = signing_key.sign(payload.as_bytes()); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + // Verify should succeed + assert!(verify_ed25519_signature(&public_key_b64, &signature_b64, &payload).is_ok()); + + // Verify with tampered payload should fail + let tampered = payload.replace("req-roundtrip", "req-tampered"); + assert!(matches!( + verify_ed25519_signature(&public_key_b64, &signature_b64, &tampered).unwrap_err(), + VerificationError::SignatureVerificationFailed + )); + } } diff --git a/crates/mocktioneer-core/tests/aps_endpoints.rs b/crates/mocktioneer-core/tests/aps_endpoints.rs index fb606a0..6bafc8d 100644 --- a/crates/mocktioneer-core/tests/aps_endpoints.rs +++ b/crates/mocktioneer-core/tests/aps_endpoints.rs @@ -221,12 +221,12 @@ fn test_aps_bid_non_standard_first_then_standard() { } #[test] -fn test_aps_bid_selects_highest_cpm_from_multiple_standard_sizes() { +fn test_aps_bid_selects_largest_area_from_multiple_standard_sizes() { let req = ApsBidRequest { pub_id: "5555".to_string(), slots: vec![ApsSlot { slot_id: "multi-standard".to_string(), - sizes: vec![[320, 50], [300, 250], [970, 250]], // 320x50=$1.80, 300x250=$2.50, 970x250=$4.20 + sizes: vec![[320, 50], [300, 250], [970, 250]], // 16000, 75000, 242500 area slot_name: Some("multi-standard".to_string()), }], page_url: None, @@ -236,7 +236,7 @@ fn test_aps_bid_selects_highest_cpm_from_multiple_standard_sizes() { let resp = build_aps_response(&req, "mocktioneer.test"); - // Should select 970x250 with highest CPM ($4.20) + // Should select 970x250 with largest area (242500) assert_eq!(resp.contextual.slots.len(), 1); assert_eq!(resp.contextual.slots[0].size, "970x250"); } diff --git a/docs/api/aps-bid.md b/docs/api/aps-bid.md index 99579ec..3971517 100644 --- a/docs/api/aps-bid.md +++ b/docs/api/aps-bid.md @@ -60,8 +60,8 @@ The response matches the real Amazon APS API format with a `contextual` wrapper: "targeting": ["amzniid", "amznp", "amznsz", "amznbid", "amznactt"], "meta": ["slotID", "mediaType", "size"], "amzniid": "019b7f82e8de7e139d6d6a593171e7a0", - "amznbid": "NC4yMA==", - "amznp": "NC4yMA==", + "amznbid": "MC4wMQ==", + "amznp": "MC4wMQ==", "amznsz": "970x250", "amznactt": "OPEN" } @@ -99,7 +99,7 @@ The response matches the real Amazon APS API format with a `contextual` wrapper: ## Size Selection -When multiple sizes are provided, Mocktioneer selects the size with the highest CPM. See the [complete pricing table](/api/#supported-sizes) for all supported sizes and their CPM values. +When multiple sizes are provided, Mocktioneer selects the standard size with the largest area. See the [supported sizes](/api/#supported-sizes) for all standard sizes. Non-standard sizes are skipped (no bid returned for that slot). @@ -115,8 +115,8 @@ The price encoding differs between real APS and Mocktioneer: Decode Mocktioneer prices: ```bash -echo "Mi41MA==" | base64 -d -# Output: 2.50 +echo "MC4wMQ==" | base64 -d +# Output: 0.01 ``` ## Examples diff --git a/docs/api/index.md b/docs/api/index.md index 0999d82..df77790 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -38,10 +38,10 @@ Mocktioneer exposes several HTTP endpoints for bid requests, creative serving, a ### Utility Endpoints -| Method | Path | Description | -| ------ | ---------- | ---------------------------- | -| GET | `/` | Service info page | -| GET | `/_/sizes` | Supported sizes with pricing | +| Method | Path | Description | +| ------ | ---------- | -------------------- | +| GET | `/` | Service info page | +| GET | `/_/sizes` | Supported ad sizes | ## Common Headers @@ -58,23 +58,23 @@ All responses include CORS headers (`Access-Control-Allow-Origin: *`, etc.). See ## Supported Ad Sizes {#supported-sizes} -Mocktioneer supports these standard IAB sizes with fixed CPM pricing: - -| Size | Name | CPM | -| ------- | ----------------------------- | ----- | -| 970x250 | Billboard | $4.20 | -| 970x90 | Large Leaderboard | $3.80 | -| 300x600 | Half Page | $3.50 | -| 160x600 | Wide Skyscraper | $3.20 | -| 728x90 | Leaderboard | $3.00 | -| 320x480 | Mobile Interstitial Portrait | $2.80 | -| 480x320 | Mobile Interstitial Landscape | $2.80 | -| 336x280 | Large Rectangle | $2.60 | -| 300x250 | Medium Rectangle | $2.50 | -| 320x100 | Large Mobile Banner | $2.20 | -| 468x60 | Banner | $2.00 | -| 320x50 | Mobile Leaderboard | $1.80 | -| 300x50 | Mobile Banner | $1.70 | +Mocktioneer supports these standard IAB sizes. All auction bids use a fixed price of `$0.01` CPM. + +| Size | Name | +| ------- | ----------------------------- | +| 970x250 | Billboard | +| 970x90 | Large Leaderboard | +| 300x600 | Half Page | +| 160x600 | Wide Skyscraper | +| 728x90 | Leaderboard | +| 320x480 | Mobile Interstitial Portrait | +| 480x320 | Mobile Interstitial Landscape | +| 336x280 | Large Rectangle | +| 300x250 | Medium Rectangle | +| 320x100 | Large Mobile Banner | +| 468x60 | Banner | +| 320x50 | Mobile Leaderboard | +| 300x50 | Mobile Banner | ::: tip Programmatic Access Use the [`/_/sizes`](#sizes-endpoint) endpoint to get this list programmatically. @@ -129,7 +129,7 @@ curl -X OPTIONS http://127.0.0.1:8787/openrtb2/auction \ ## Sizes Endpoint {#sizes-endpoint} -Returns all supported ad sizes with their CPM values as JSON. +Returns all supported standard ad sizes as JSON. ``` GET /_/sizes @@ -140,19 +140,19 @@ GET /_/sizes ```json { "sizes": [ - { "width": 160, "height": 600, "cpm": 3.2 }, - { "width": 300, "height": 50, "cpm": 1.7 }, - { "width": 300, "height": 250, "cpm": 2.5 }, - { "width": 300, "height": 600, "cpm": 3.5 }, - { "width": 320, "height": 50, "cpm": 1.8 }, - { "width": 320, "height": 100, "cpm": 2.2 }, - { "width": 320, "height": 480, "cpm": 2.8 }, - { "width": 336, "height": 280, "cpm": 2.6 }, - { "width": 468, "height": 60, "cpm": 2.0 }, - { "width": 480, "height": 320, "cpm": 2.8 }, - { "width": 728, "height": 90, "cpm": 3.0 }, - { "width": 970, "height": 90, "cpm": 3.8 }, - { "width": 970, "height": 250, "cpm": 4.2 } + { "width": 160, "height": 600 }, + { "width": 300, "height": 50 }, + { "width": 300, "height": 250 }, + { "width": 300, "height": 600 }, + { "width": 320, "height": 50 }, + { "width": 320, "height": 100 }, + { "width": 320, "height": 480 }, + { "width": 336, "height": 280 }, + { "width": 468, "height": 60 }, + { "width": 480, "height": 320 }, + { "width": 728, "height": 90 }, + { "width": 970, "height": 90 }, + { "width": 970, "height": 250 } ] } ``` diff --git a/docs/api/openrtb-auction.md b/docs/api/openrtb-auction.md index 662a16a..0a60950 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -48,11 +48,6 @@ Content-Type: application/json { "w": 320, "h": 50 } ] }, - "ext": { - "mocktioneer": { - "bid": 2.5 - } - } } ], "ext": { @@ -179,21 +174,6 @@ curl -X POST http://127.0.0.1:8787/openrtb2/auction \ }' | jq . ``` -### With Price Override - -```bash -curl -X POST http://127.0.0.1:8787/openrtb2/auction \ - -H 'Content-Type: application/json' \ - -d '{ - "id": "custom-price", - "imp": [{ - "id": "1", - "banner": {"w": 300, "h": 250}, - "ext": {"mocktioneer": {"bid": 10.00}} - }] - }' | jq . -``` - ## Error Responses ### Missing Impressions (422) diff --git a/docs/index.md b/docs/index.md index 256d0e1..4b18740 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,7 @@ features: - title: Deterministic Bids details: Predictable banner bids and simple creative templates for reliable QA flows - title: OpenRTB 2.x Compliant - details: Full banner inventory support with standard bid responses and price overrides + details: Full banner inventory support with standard bid responses and fixed pricing - title: APS TAM Support details: Amazon Publisher Services bid endpoint with real API format compatibility - title: Zero Backend diff --git a/docs/integrations/prebid-server.md b/docs/integrations/prebid-server.md index 8a5f062..3de014b 100644 --- a/docs/integrations/prebid-server.md +++ b/docs/integrations/prebid-server.md @@ -62,32 +62,6 @@ adapters: } ``` -### With Price Override - -```json -{ - "id": "test-request", - "imp": [ - { - "id": "imp-1", - "banner": { - "w": 300, - "h": 250 - }, - "ext": { - "prebid": { - "bidder": { - "mocktioneer": { - "bid": 5.0 - } - } - } - } - } - ] -} -``` - ### Custom Endpoint Per Request Override the endpoint for specific requests: @@ -102,9 +76,7 @@ Override the endpoint for specific requests: }, "prebid": { "bidder": { - "mocktioneer": { - "bid": 2.5 - } + "mocktioneer": {} } } } @@ -115,10 +87,11 @@ Override the endpoint for specific requests: ## Parameters -| Parameter | Location | Type | Description | -| ---------- | ------------------------------------- | ------ | ------------------------- | -| `endpoint` | `imp[].ext.bidder` | string | Override auction endpoint | -| `bid` | `imp[].ext.prebid.bidder.mocktioneer` | float | Override bid price | +| Parameter | Location | Type | Description | +| ---------- | ------------------ | ------ | ------------------------- | +| `endpoint` | `imp[].ext.bidder` | string | Override auction endpoint | + +Mocktioneer always returns a fixed bid price of `$0.01` CPM. ## Response Handling @@ -133,7 +106,7 @@ Prebid Server processes Mocktioneer responses like any other bidder: { "id": "019abc-mocktioneer", "impid": "imp-1", - "price": 2.5, + "price": 0.01, "adm": "", "crid": "019abc-mocktioneer", "w": 300, @@ -160,7 +133,7 @@ curl -X POST http://localhost:8000/openrtb2/auction \ "ext": { "prebid": { "bidder": { - "mocktioneer": {"bid": 3.00} + "mocktioneer": {} } } } @@ -181,7 +154,7 @@ curl -X POST http://localhost:8787/openrtb2/auction \ "imp": [{ "id": "1", "banner": {"w": 300, "h": 250}, - "ext": {"mocktioneer": {"bid": 2.50}} + "ext": {"mocktioneer": {}} }] }' | jq . ``` @@ -198,7 +171,7 @@ curl -X POST http://localhost:8787/openrtb2/auction \ "ext": { "prebid": { "bidder": { - "mocktioneer": { "bid": 2.0 } + "mocktioneer": {} } } } @@ -209,7 +182,7 @@ curl -X POST http://localhost:8787/openrtb2/auction \ "ext": { "prebid": { "bidder": { - "mocktioneer": { "bid": 2.5 } + "mocktioneer": {} } } } @@ -220,7 +193,7 @@ curl -X POST http://localhost:8787/openrtb2/auction \ "ext": { "prebid": { "bidder": { - "mocktioneer": { "bid": 4.0 } + "mocktioneer": {} } } } @@ -288,7 +261,7 @@ Include Mocktioneer alongside real bidders: "ext": { "prebid": { "bidder": { - "mocktioneer": { "bid": 2.0 }, + "mocktioneer": {}, "appnexus": { "placementId": "12345" } } } @@ -300,17 +273,17 @@ Include Mocktioneer alongside real bidders: ### Price Floor Testing -Test floor enforcement: +Test floor enforcement (Mocktioneer bids at `$0.01` which will be below most floors): ```json { "imp": [ { - "bidfloor": 3.0, + "bidfloor": 1.0, "ext": { "prebid": { "bidder": { - "mocktioneer": { "bid": 2.5 } // Below floor + "mocktioneer": {} } } } diff --git a/docs/integrations/prebidjs.md b/docs/integrations/prebidjs.md index fc57300..2e301dd 100644 --- a/docs/integrations/prebidjs.md +++ b/docs/integrations/prebidjs.md @@ -73,24 +73,8 @@ params: { | Parameter | Type | Required | Description | | ---------- | ------ | -------- | --------------------------- | | `endpoint` | string | No | Custom auction endpoint URL | -| `bid` | float | No | Override bid price (CPM) | -### Price Override - -Force a specific bid price for testing: - -```javascript -bids: [ - { - bidder: 'mocktioneer', - params: { - bid: 5.0, // Force $5.00 CPM - }, - }, -] -``` - -The price override is passed via `imp[].ext.mocktioneer.bid` in the OpenRTB request. +Mocktioneer always returns a fixed bid price of `$0.01` CPM. ## Example Page @@ -180,7 +164,7 @@ var adUnits = [ bids: [ { bidder: 'mocktioneer', - params: { bid: 3.0 }, + params: {}, }, ], }, @@ -197,7 +181,7 @@ var adUnits = [ bids: [ { bidder: 'mocktioneer', - params: { bid: 2.0 }, + params: {}, }, ], }, @@ -214,7 +198,7 @@ var adUnits = [ bids: [ { bidder: 'mocktioneer', - params: { bid: 1.5 }, + params: {}, }, ], }, @@ -227,16 +211,6 @@ var adUnits = [ Mocktioneer returns a bid for valid banner impressions (non-standard sizes are coerced to 300x250). If you need a no-bid path, filter it on the client side or use the mediation endpoint with a `price_floor` above the bids you send. -### High CPM Testing - -Test price floor logic: - -```javascript -params: { - bid: 100.0 // $100 CPM -} -``` - ### Multiple Bidders Compare Mocktioneer with other bidders: @@ -245,7 +219,7 @@ Compare Mocktioneer with other bidders: bids: [ { bidder: 'mocktioneer', - params: { bid: 2.0 }, + params: {}, }, { bidder: 'appnexus', From 282ed3096e5eb7df6874cb7450e3afb6ae6c51f1 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 6 Mar 2026 12:15:17 -0500 Subject: [PATCH 04/13] Finalize PR #80 follow-ups for fixed-pricing contract Align remaining docs with fixed /bin/zsh.01 bidding and update Playwright /_/sizes assertions to match the cpm-free response, so review cleanups and CI checks reflect the current API behavior. --- docs/api/index.md | 8 ++-- docs/api/openrtb-auction.md | 45 +++++++++++--------- docs/guide/what-is-mocktioneer.md | 18 ++++---- docs/integrations/index.md | 17 +++++--- tests/playwright/creative-visibility.test.ts | 3 +- 5 files changed, 52 insertions(+), 39 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index df77790..7a12a55 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -38,10 +38,10 @@ Mocktioneer exposes several HTTP endpoints for bid requests, creative serving, a ### Utility Endpoints -| Method | Path | Description | -| ------ | ---------- | -------------------- | -| GET | `/` | Service info page | -| GET | `/_/sizes` | Supported ad sizes | +| Method | Path | Description | +| ------ | ---------- | ------------------ | +| GET | `/` | Service info page | +| GET | `/_/sizes` | Supported ad sizes | ## Common Headers diff --git a/docs/api/openrtb-auction.md b/docs/api/openrtb-auction.md index 0a60950..6efdb4b 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -47,7 +47,7 @@ Content-Type: application/json { "w": 300, "h": 250 }, { "w": 320, "h": 50 } ] - }, + } } ], "ext": { @@ -65,23 +65,23 @@ Content-Type: application/json ### Request Fields -| Field | Type | Required | Description | -| ------------------------------ | ------- | -------- | ------------------------------------- | -| `id` | string | Yes | Request ID | -| `imp` | array | Yes | Array of impressions (min 1) | -| `imp[].id` | string | Yes | Impression ID | -| `imp[].banner` | object | Yes\* | Banner object (\*or other media type) | -| `imp[].banner.w` | integer | No | Width in pixels | -| `imp[].banner.h` | integer | No | Height in pixels | -| `imp[].banner.format` | array | No | Array of size objects | -| `ext.trusted_server.version` | string | No | Signing protocol version (`1.1`) | -| `ext.trusted_server.signature` | string | No | Signature for canonical payload | -| `ext.trusted_server.kid` | string | No | Key ID used for signature | -| `ext.trusted_server.request_host` | string | No | Host included in signed payload | -| `ext.trusted_server.request_scheme` | string | No | Scheme included in signed payload | -| `ext.trusted_server.ts` | integer | No | Unix timestamp (milliseconds) | -| `site` | object | No | Site information | -| `site.domain` | string | No | Domain for signature verification | +| Field | Type | Required | Description | +| ----------------------------------- | ------- | -------- | ------------------------------------- | +| `id` | string | Yes | Request ID | +| `imp` | array | Yes | Array of impressions (min 1) | +| `imp[].id` | string | Yes | Impression ID | +| `imp[].banner` | object | Yes\* | Banner object (\*or other media type) | +| `imp[].banner.w` | integer | No | Width in pixels | +| `imp[].banner.h` | integer | No | Height in pixels | +| `imp[].banner.format` | array | No | Array of size objects | +| `ext.trusted_server.version` | string | No | Signing protocol version (`1.1`) | +| `ext.trusted_server.signature` | string | No | Signature for canonical payload | +| `ext.trusted_server.kid` | string | No | Key ID used for signature | +| `ext.trusted_server.request_host` | string | No | Host included in signed payload | +| `ext.trusted_server.request_scheme` | string | No | Scheme included in signed payload | +| `ext.trusted_server.ts` | integer | No | Unix timestamp (milliseconds) | +| `site` | object | No | Site information | +| `site.domain` | string | No | Domain for signature verification | ### Size Resolution @@ -223,7 +223,14 @@ Mocktioneer supports optional request signature verification. When `site.domain` The signed payload is canonical JSON: ```json -{"version":"1.1","kid":"...","host":"...","scheme":"https","id":"...","ts":1706900000000} +{ + "version": "1.1", + "kid": "...", + "host": "...", + "scheme": "https", + "id": "...", + "ts": 1706900000000 +} ``` The JWKS is fetched from `https://{site.domain}/.well-known/trusted-server.json`. Verification failures are logged but don't reject the request. diff --git a/docs/guide/what-is-mocktioneer.md b/docs/guide/what-is-mocktioneer.md index d3451e5..53e4a2d 100644 --- a/docs/guide/what-is-mocktioneer.md +++ b/docs/guide/what-is-mocktioneer.md @@ -34,7 +34,7 @@ Mocktioneer provides: - Build ad tech features without waiting for real bidders - Debug integration issues with predictable responses -- Test edge cases with controlled bid prices +- Test edge cases with fixed, deterministic bid pricing ### CI/CD @@ -44,14 +44,14 @@ Mocktioneer provides: ## Key Features -| Feature | Description | -| --------------- | -------------------------------------------- | -| Multi-platform | Runs on Fastly, Cloudflare, and native Axum | -| Manifest-driven | Single `edgezero.toml` configures everything | -| Price control | Override bid prices via request extensions | -| Standard sizes | Supports common IAB ad sizes | -| Cookie tracking | Optional pixel tracking with `mtkid` cookie | -| CORS enabled | Works with browser-based clients | +| Feature | Description | +| --------------- | --------------------------------------------- | +| Multi-platform | Runs on Fastly, Cloudflare, and native Axum | +| Manifest-driven | Single `edgezero.toml` configures everything | +| Fixed pricing | Always returns `$0.01` CPM for generated bids | +| Standard sizes | Supports common IAB ad sizes | +| Cookie tracking | Optional pixel tracking with `mtkid` cookie | +| CORS enabled | Works with browser-based clients | ## How It Works diff --git a/docs/integrations/index.md b/docs/integrations/index.md index b450423..7633273 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -48,7 +48,7 @@ Mocktioneer acts as a drop-in replacement for real bidders during development an - Same request always produces same response - No flaky tests due to bidder variability -- Controlled bid prices for testing scenarios +- Fixed $0.01 bids for predictable floor testing ### No External Dependencies @@ -136,13 +136,20 @@ Test creative rendering pipeline: Test price handling and floor logic: -```javascript -// Override bid price -params: { - bid: 5.0 // Force $5 CPM +```json +{ + "imp": [ + { + "id": "1", + "bidfloor": 1.0, + "banner": { "w": 300, "h": 250 } + } + ] } ``` +Mocktioneer always bids at `$0.01` CPM, so floors above that value should reject bids. + ### Error Handling Test error scenarios: diff --git a/tests/playwright/creative-visibility.test.ts b/tests/playwright/creative-visibility.test.ts index 68245aa..7fa51f2 100644 --- a/tests/playwright/creative-visibility.test.ts +++ b/tests/playwright/creative-visibility.test.ts @@ -10,7 +10,6 @@ import { test, expect } from '@playwright/test'; interface AdSize { width: number; height: number; - cpm: number; } // Fetched from /_/sizes endpoint before tests run @@ -34,7 +33,7 @@ test.describe('Creative visibility tests', () => { for (const size of data.sizes) { expect(typeof size.width).toBe('number'); expect(typeof size.height).toBe('number'); - expect(typeof size.cpm).toBe('number'); + expect(size).not.toHaveProperty('cpm'); } }); From a7dbc678553ba42b87cb75a8ae02f56ccdbb8a54 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 12 Mar 2026 13:56:54 -0500 Subject: [PATCH 05/13] Address PR review feedback - Normalize X-Forwarded-Proto: split on comma, trim, lowercase, fall back to request URI scheme before defaulting to https - Add canonicalize_host() to strip default ports (:443/:80) and lowercase both sides of host/scheme cross-checks in verification - Log deprecation warning when imp.ext.mocktioneer.bid is present but ignored - Remove dead pricing code: get_cpm(), DEFAULT_CPM, MAX_AREA_BONUS - Update CLAUDE.md key constants to reflect FIXED_BID_CPM - Remove unused JS variable 'b' in creative template - Strengthen timestamp test assertions with matches! on error variant --- CLAUDE.md | 3 +- crates/mocktioneer-core/src/auction.rs | 40 ++++++++++----------- crates/mocktioneer-core/src/routes.rs | 15 ++++++-- crates/mocktioneer-core/src/verification.rs | 30 ++++++++++++---- 4 files changed, 55 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f405629..a8317e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,8 +150,7 @@ through `render.rs`. Do not inline ad markup in handlers. ## Key Constants -- `DEFAULT_CPM: f64 = 1.50` — base price for non-standard sizes -- `MAX_AREA_BONUS: f64 = 3.00` — area-based bonus cap +- `FIXED_BID_CPM: f64 = 0.01` — fixed price for all Mocktioneer-generated bids - `SIZE_MAP` — 13 standard IAB sizes via `phf::Map` (300x250, 728x90, 320x50, etc.) ## CI Gates diff --git a/crates/mocktioneer-core/src/auction.rs b/crates/mocktioneer-core/src/auction.rs index abb8ce4..78012e2 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -7,20 +7,14 @@ use phf::phf_map; use uuid::Uuid; // ============================================================================ -// Standard Ad Sizes - single source of truth for supported sizes and pricing +// Standard Ad Sizes - single source of truth for supported sizes // ============================================================================ -/// Default CPM for non-standard sizes (base price before area adjustment). -pub const DEFAULT_CPM: f64 = 1.50; - /// Fixed CPM used for all Mocktioneer-generated bids. pub const FIXED_BID_CPM: f64 = 0.01; -/// Maximum area-based bonus added to DEFAULT_CPM for non-standard sizes. -/// Final CPM = DEFAULT_CPM + min(area/100000, MAX_AREA_BONUS) -pub const MAX_AREA_BONUS: f64 = 3.00; - -/// Compile-time perfect hash map for standard sizes: "WxH" -> cpm. +/// Compile-time perfect hash map for standard sizes: "WxH" -> (legacy CPM, unused). +/// Only membership (`contains_key`) and key iteration are used at runtime. /// Zero runtime initialization cost. static SIZE_MAP: phf::Map<&'static str, f64> = phf_map! { // Desktop & General Display Sizes @@ -51,18 +45,6 @@ pub fn is_standard_size(w: i64, h: i64) -> bool { SIZE_MAP.contains_key(size_key(w, h).as_str()) } -/// Get CPM for a size. Returns configured CPM for standard sizes, area-based fallback otherwise. -pub fn get_cpm(w: i64, h: i64) -> f64 { - SIZE_MAP - .get(size_key(w, h).as_str()) - .copied() - .unwrap_or_else(|| { - // Fallback: area-based pricing for non-standard sizes - let area = (w * h) as f64; - ((DEFAULT_CPM + (area / 100000.0).min(MAX_AREA_BONUS)) * 100.0).round() / 100.0 - }) -} - /// Returns an iterator over all standard ad sizes as (width, height) tuples. /// Useful for generating test fixtures or validating external configurations. pub fn standard_sizes() -> impl Iterator { @@ -132,6 +114,22 @@ pub fn build_openrtb_response( let bid_id = new_id(); let crid = format!("mocktioneer-{}", imp.id); + // Warn when callers supply a bid override that is no longer honored + if imp + .ext + .as_ref() + .and_then(|e| e.mocktioneer.as_ref()) + .and_then(|m| m.bid) + .is_some() + { + log::warn!( + "imp[{}].ext.mocktioneer.bid is deprecated and ignored; \ + all bids use fixed price ${}", + imp.id, + FIXED_BID_CPM + ); + } + let price = FIXED_BID_CPM; bids.push(OpenrtbBid { diff --git a/crates/mocktioneer-core/src/routes.rs b/crates/mocktioneer-core/src/routes.rs index 51a72e7..97ffdf0 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -239,13 +239,22 @@ pub async fn handle_openrtb_auction( ForwardedHost(host): ForwardedHost, ValidatedJson(req): ValidatedJson, ) -> Result { - // Determine server-observed scheme from X-Forwarded-Proto header, default to "https" + // Normalize scheme: first token, trimmed, lowercased (handles multi-valued + // headers like "https, http" from chained proxies and case variations). let scheme = ctx .request() .headers() .get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) - .unwrap_or("https"); + .and_then(|v| v.split(',').next()) + .map(|s| s.trim().to_lowercase()) + .unwrap_or_else(|| { + ctx.request() + .uri() + .scheme_str() + .unwrap_or("https") + .to_lowercase() + }); // Capture signature verification status for metadata let signature_status = if let Some(domain) = req.site.as_ref().and_then(|s| s.domain.as_deref()) @@ -256,7 +265,7 @@ pub async fn handle_openrtb_auction( req.ext.as_ref(), domain, &host, - scheme, + &scheme, ) .await { diff --git a/crates/mocktioneer-core/src/verification.rs b/crates/mocktioneer-core/src/verification.rs index 0cd9b6a..b875399 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -271,6 +271,16 @@ fn current_time_ms() -> Result { .map_err(|_| VerificationError::InvalidSignature("System clock error".to_string())) } +/// Strip default ports (:443 for https, :80 for http) and lowercase the host +/// so that `example.com:443` matches `example.com` from the signer. +fn canonicalize_host(host: &str) -> String { + let h = host.trim(); + h.strip_suffix(":443") + .or_else(|| h.strip_suffix(":80")) + .unwrap_or(h) + .to_lowercase() +} + fn check_timestamp_freshness(timestamp_ms: u64) -> Result<(), VerificationError> { let now_ms = current_time_ms()?; let diff = now_ms.abs_diff(timestamp_ms); @@ -335,15 +345,19 @@ pub async fn verify_request_id_signature( VerificationError::InvalidSignature("Missing ext.trusted_server.ts".to_string()), )?; - // Cross-check ext fields against server-observed values - if request_host != trusted_host { + // Cross-check ext fields against server-observed values (normalized) + let canon_ext_host = canonicalize_host(request_host); + let canon_trusted_host = canonicalize_host(trusted_host); + if canon_ext_host != canon_trusted_host { return Err(VerificationError::InvalidSignature(format!( "ext.trusted_server.request_host '{}' does not match server-observed host '{}'", request_host, trusted_host ))); } - if request_scheme != trusted_scheme { + let canon_ext_scheme = request_scheme.trim().to_lowercase(); + let canon_trusted_scheme = trusted_scheme.trim().to_lowercase(); + if canon_ext_scheme != canon_trusted_scheme { return Err(VerificationError::InvalidSignature(format!( "ext.trusted_server.request_scheme '{}' does not match server-observed scheme '{}'", request_scheme, trusted_scheme @@ -675,8 +689,9 @@ mod tests { "example.com", "https", )); - let err = result.unwrap_err().to_string(); - assert!(err.contains("stale")); + let err = result.unwrap_err(); + assert!(matches!(err, VerificationError::InvalidSignature(_))); + assert!(err.to_string().contains("stale")); } #[test] @@ -703,8 +718,9 @@ mod tests { "example.com", "https", )); - let err = result.unwrap_err().to_string(); - assert!(err.contains("stale")); + let err = result.unwrap_err(); + assert!(matches!(err, VerificationError::InvalidSignature(_))); + assert!(err.to_string().contains("stale")); } #[test] From 303ae473202459268adfc531ee2b82c938b36fd5 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 13 Mar 2026 16:23:10 -0500 Subject: [PATCH 06/13] Address PR review: zero out SIZE_MAP values, lazy error alloc, canonicalize_host tests --- crates/mocktioneer-core/src/auction.rs | 31 +++++----- crates/mocktioneer-core/src/verification.rs | 67 ++++++++++----------- 2 files changed, 47 insertions(+), 51 deletions(-) diff --git a/crates/mocktioneer-core/src/auction.rs b/crates/mocktioneer-core/src/auction.rs index 78012e2..1ca7513 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -13,25 +13,26 @@ use uuid::Uuid; /// Fixed CPM used for all Mocktioneer-generated bids. pub const FIXED_BID_CPM: f64 = 0.01; -/// Compile-time perfect hash map for standard sizes: "WxH" -> (legacy CPM, unused). -/// Only membership (`contains_key`) and key iteration are used at runtime. +/// Compile-time perfect hash map for standard sizes. +/// Values unused — map exists for key membership only. +/// Only `contains_key` and key iteration are used at runtime. /// Zero runtime initialization cost. static SIZE_MAP: phf::Map<&'static str, f64> = phf_map! { // Desktop & General Display Sizes - "300x250" => 2.50, // Medium Rectangle - "336x280" => 2.60, // Large Rectangle - "728x90" => 3.00, // Leaderboard - "970x90" => 3.80, // Large Leaderboard - "160x600" => 3.20, // Wide Skyscraper - "300x600" => 3.50, // Half Page - "970x250" => 4.20, // Billboard - "468x60" => 2.00, // Banner + "300x250" => 0.0, // Medium Rectangle + "336x280" => 0.0, // Large Rectangle + "728x90" => 0.0, // Leaderboard + "970x90" => 0.0, // Large Leaderboard + "160x600" => 0.0, // Wide Skyscraper + "300x600" => 0.0, // Half Page + "970x250" => 0.0, // Billboard + "468x60" => 0.0, // Banner // Mobile-Specific Sizes - "320x50" => 1.80, // Mobile Leaderboard - "300x50" => 1.70, // Mobile Banner (alternative) - "320x100" => 2.20, // Large Mobile Banner - "320x480" => 2.80, // Mobile Interstitial Portrait - "480x320" => 2.80, // Mobile Interstitial Landscape + "320x50" => 0.0, // Mobile Leaderboard + "300x50" => 0.0, // Mobile Banner (alternative) + "320x100" => 0.0, // Large Mobile Banner + "320x480" => 0.0, // Mobile Interstitial Portrait + "480x320" => 0.0, // Mobile Interstitial Landscape }; /// Format dimensions as lookup key. diff --git a/crates/mocktioneer-core/src/verification.rs b/crates/mocktioneer-core/src/verification.rs index b875399..dd5c6b4 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -245,23 +245,23 @@ fn build_signing_payload( fn required_ext_str<'a>( ext_obj: &'a serde_json::Value, field: &str, - missing_error: VerificationError, + missing_error: impl FnOnce() -> VerificationError, ) -> Result<&'a str, VerificationError> { ext_obj .get(field) .and_then(serde_json::Value::as_str) - .ok_or(missing_error) + .ok_or_else(missing_error) } fn required_ext_u64( ext_obj: &serde_json::Value, field: &str, - missing_error: VerificationError, + missing_error: impl FnOnce() -> VerificationError, ) -> Result { ext_obj .get(field) .and_then(serde_json::Value::as_u64) - .ok_or(missing_error) + .ok_or_else(missing_error) } fn current_time_ms() -> Result { @@ -307,43 +307,29 @@ pub async fn verify_request_id_signature( VerificationError::InvalidSignature("Missing ext.trusted_server".to_string()) })?; - let signature = required_ext_str( - ext_obj, - "signature", - VerificationError::InvalidSignature("Missing ext.trusted_server.signature".to_string()), - )?; + let signature = required_ext_str(ext_obj, "signature", || { + VerificationError::InvalidSignature("Missing ext.trusted_server.signature".to_string()) + })?; - let key_id = required_ext_str( - ext_obj, - "kid", - VerificationError::KeyNotFound("Missing ext.trusted_server.kid".to_string()), - )?; + let key_id = required_ext_str(ext_obj, "kid", || { + VerificationError::KeyNotFound("Missing ext.trusted_server.kid".to_string()) + })?; - let version = required_ext_str( - ext_obj, - "version", - VerificationError::InvalidSignature("Missing ext.trusted_server.version".to_string()), - )?; + let version = required_ext_str(ext_obj, "version", || { + VerificationError::InvalidSignature("Missing ext.trusted_server.version".to_string()) + })?; - let request_host = required_ext_str( - ext_obj, - "request_host", - VerificationError::InvalidSignature("Missing ext.trusted_server.request_host".to_string()), - )?; + let request_host = required_ext_str(ext_obj, "request_host", || { + VerificationError::InvalidSignature("Missing ext.trusted_server.request_host".to_string()) + })?; - let request_scheme = required_ext_str( - ext_obj, - "request_scheme", - VerificationError::InvalidSignature( - "Missing ext.trusted_server.request_scheme".to_string(), - ), - )?; + let request_scheme = required_ext_str(ext_obj, "request_scheme", || { + VerificationError::InvalidSignature("Missing ext.trusted_server.request_scheme".to_string()) + })?; - let timestamp = required_ext_u64( - ext_obj, - "ts", - VerificationError::InvalidSignature("Missing ext.trusted_server.ts".to_string()), - )?; + let timestamp = required_ext_u64(ext_obj, "ts", || { + VerificationError::InvalidSignature("Missing ext.trusted_server.ts".to_string()) + })?; // Cross-check ext fields against server-observed values (normalized) let canon_ext_host = canonicalize_host(request_host); @@ -772,4 +758,13 @@ mod tests { VerificationError::SignatureVerificationFailed )); } + + #[test] + fn canonicalize_host_cases() { + assert_eq!(canonicalize_host("EXAMPLE.COM"), "example.com"); + assert_eq!(canonicalize_host("example.com:443"), "example.com"); + assert_eq!(canonicalize_host("example.com:80"), "example.com"); + assert_eq!(canonicalize_host("example.com:8080"), "example.com:8080"); + assert_eq!(canonicalize_host(" example.com "), "example.com"); + } } From 71c84eec4d76e5c56fff335baf308fd595ea47b1 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 23 Mar 2026 14:58:58 -0500 Subject: [PATCH 07/13] Fix request verification: compare request_host against site.domain, not bidder host The host cross-check was comparing ext.trusted_server.request_host (the publisher's domain) against the bidder's own ForwardedHost, which will never match in header bidding. Changed to compare against site.domain from the OpenRTB request instead. Also removed the redundant scheme cross-check since request_scheme is already verified cryptographically as part of the signed payload. --- crates/mocktioneer-core/src/routes.rs | 20 +------ crates/mocktioneer-core/src/verification.rs | 64 +++++---------------- 2 files changed, 14 insertions(+), 70 deletions(-) diff --git a/crates/mocktioneer-core/src/routes.rs b/crates/mocktioneer-core/src/routes.rs index 97ffdf0..a81821c 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -239,23 +239,6 @@ pub async fn handle_openrtb_auction( ForwardedHost(host): ForwardedHost, ValidatedJson(req): ValidatedJson, ) -> Result { - // Normalize scheme: first token, trimmed, lowercased (handles multi-valued - // headers like "https, http" from chained proxies and case variations). - let scheme = ctx - .request() - .headers() - .get("x-forwarded-proto") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.split(',').next()) - .map(|s| s.trim().to_lowercase()) - .unwrap_or_else(|| { - ctx.request() - .uri() - .scheme_str() - .unwrap_or("https") - .to_lowercase() - }); - // Capture signature verification status for metadata let signature_status = if let Some(domain) = req.site.as_ref().and_then(|s| s.domain.as_deref()) { @@ -264,8 +247,7 @@ pub async fn handle_openrtb_auction( &req.id, req.ext.as_ref(), domain, - &host, - &scheme, + domain, ) .await { diff --git a/crates/mocktioneer-core/src/verification.rs b/crates/mocktioneer-core/src/verification.rs index dd5c6b4..07656f4 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -300,8 +300,7 @@ pub async fn verify_request_id_signature( request_id: &str, ext: Option<&serde_json::Value>, domain: &str, - trusted_host: &str, - trusted_scheme: &str, + site_domain: &str, ) -> Result { let ext_obj = ext.and_then(|e| e.get("trusted_server")).ok_or_else(|| { VerificationError::InvalidSignature("Missing ext.trusted_server".to_string()) @@ -331,24 +330,22 @@ pub async fn verify_request_id_signature( VerificationError::InvalidSignature("Missing ext.trusted_server.ts".to_string()) })?; - // Cross-check ext fields against server-observed values (normalized) + // Cross-check: the signer's claimed host must match the publisher's + // site.domain from the OpenRTB request. The bidder's own host (ForwardedHost) + // is intentionally NOT compared — in header bidding the publisher's domain + // and the bidder's domain are always different. let canon_ext_host = canonicalize_host(request_host); - let canon_trusted_host = canonicalize_host(trusted_host); - if canon_ext_host != canon_trusted_host { + let canon_site_domain = canonicalize_host(site_domain); + if canon_ext_host != canon_site_domain { return Err(VerificationError::InvalidSignature(format!( - "ext.trusted_server.request_host '{}' does not match server-observed host '{}'", - request_host, trusted_host + "ext.trusted_server.request_host '{}' does not match site.domain '{}'", + request_host, site_domain ))); } - let canon_ext_scheme = request_scheme.trim().to_lowercase(); - let canon_trusted_scheme = trusted_scheme.trim().to_lowercase(); - if canon_ext_scheme != canon_trusted_scheme { - return Err(VerificationError::InvalidSignature(format!( - "ext.trusted_server.request_scheme '{}' does not match server-observed scheme '{}'", - request_scheme, trusted_scheme - ))); - } + // Note: request_scheme is part of the signed payload and verified + // cryptographically. No separate cross-check is needed since site.domain + // does not carry scheme information. // Enforce timestamp freshness to prevent replay attacks check_timestamp_freshness(timestamp)?; @@ -413,7 +410,6 @@ mod tests { Some(&ext), "example.com", "example.com", - "https", )); assert!(matches!( result.unwrap_err(), @@ -438,7 +434,6 @@ mod tests { Some(&ext), "example.com", "example.com", - "https", )); assert!(matches!( result.unwrap_err(), @@ -461,7 +456,6 @@ mod tests { Some(&ext), "example.com", "example.com", - "https", )); assert!(matches!( result.unwrap_err(), @@ -481,7 +475,6 @@ mod tests { None, "example.com", "example.com", - "https", )); assert!(matches!( result.unwrap_err(), @@ -511,7 +504,6 @@ mod tests { Some(&ext), "example.com", "example.com", - "https", )); assert!(matches!( result.unwrap_err(), @@ -618,37 +610,9 @@ mod tests { Some(&ext), "example.com", "example.com", - "https", )); let err = result.unwrap_err().to_string(); - assert!(err.contains("does not match server-observed host")); - } - - #[test] - fn verify_scheme_mismatch_rejected() { - let now_ms = current_time_ms().unwrap(); - let ext = serde_json::json!({ - "trusted_server": { - "signature": "test-sig", - "kid": "test-key", - "version": "1.1", - "request_host": "example.com", - "request_scheme": "http", - "ts": now_ms - } - }); - - let ctx = create_test_context(); - let result = block_on(verify_request_id_signature( - &ctx, - "test-id", - Some(&ext), - "example.com", - "example.com", - "https", - )); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not match server-observed scheme")); + assert!(err.contains("does not match site.domain")); } #[test] @@ -673,7 +637,6 @@ mod tests { Some(&ext), "example.com", "example.com", - "https", )); let err = result.unwrap_err(); assert!(matches!(err, VerificationError::InvalidSignature(_))); @@ -702,7 +665,6 @@ mod tests { Some(&ext), "example.com", "example.com", - "https", )); let err = result.unwrap_err(); assert!(matches!(err, VerificationError::InvalidSignature(_))); From 5a8fc3ee5a8789b4f145769e2695266436b85c7d Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 23 Mar 2026 16:48:39 -0500 Subject: [PATCH 08/13] Replace phf SIZE_MAP with const STANDARD_SIZES array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the phf dependency entirely — SIZE_MAP values were zeroed out and only used for key membership checks. Replace with a sorted const array of (i64, i64) tuples. O(n) scan over 13 entries is trivially fast and eliminates the build-time codegen dependency. --- Cargo.lock | 60 +--------------------- Cargo.toml | 1 - crates/mocktioneer-core/Cargo.toml | 1 - crates/mocktioneer-core/src/auction.rs | 71 +++++++++----------------- crates/mocktioneer-core/src/routes.rs | 2 +- 5 files changed, 25 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 076dae3..5d70cda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,7 +1499,6 @@ dependencies = [ "futures-util", "handlebars", "log", - "phf", "serde", "serde_json", "serde_repr", @@ -1632,48 +1631,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project" version = "1.1.10" @@ -1811,7 +1768,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand", "ring", "rustc-hash", "rustls", @@ -1852,15 +1809,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" @@ -2290,12 +2238,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 05e4399..837b718 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ futures-util = "0.3.31" handlebars = "6" log = { version = "0.4", features = ["serde"] } mocktioneer-core = { path = "crates/mocktioneer-core" } -phf = { version = "0.11", features = ["macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" diff --git a/crates/mocktioneer-core/Cargo.toml b/crates/mocktioneer-core/Cargo.toml index 16ff986..3bdbd7d 100644 --- a/crates/mocktioneer-core/Cargo.toml +++ b/crates/mocktioneer-core/Cargo.toml @@ -13,7 +13,6 @@ edgezero-core = { workspace = true } futures-util = { workspace = true } handlebars = { workspace = true } log = { workspace = true } -phf = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } diff --git a/crates/mocktioneer-core/src/auction.rs b/crates/mocktioneer-core/src/auction.rs index 1ca7513..24588cb 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -3,7 +3,6 @@ use crate::openrtb::{ Bid as OpenrtbBid, Imp as OpenrtbImp, MediaType, OpenRTBRequest, OpenRTBResponse, SeatBid, }; use crate::render::{iframe_html, CreativeMetadata, SignatureStatus}; -use phf::phf_map; use uuid::Uuid; // ============================================================================ @@ -11,60 +10,36 @@ use uuid::Uuid; // ============================================================================ /// Fixed CPM used for all Mocktioneer-generated bids. -pub const FIXED_BID_CPM: f64 = 0.01; +pub const FIXED_BID_CPM: f64 = 0.20; -/// Compile-time perfect hash map for standard sizes. -/// Values unused — map exists for key membership only. -/// Only `contains_key` and key iteration are used at runtime. -/// Zero runtime initialization cost. -static SIZE_MAP: phf::Map<&'static str, f64> = phf_map! { +/// Standard IAB ad sizes supported by Mocktioneer. +/// Sorted by (width, height) for deterministic iteration order. +const STANDARD_SIZES: [(i64, i64); 13] = [ // Desktop & General Display Sizes - "300x250" => 0.0, // Medium Rectangle - "336x280" => 0.0, // Large Rectangle - "728x90" => 0.0, // Leaderboard - "970x90" => 0.0, // Large Leaderboard - "160x600" => 0.0, // Wide Skyscraper - "300x600" => 0.0, // Half Page - "970x250" => 0.0, // Billboard - "468x60" => 0.0, // Banner - // Mobile-Specific Sizes - "320x50" => 0.0, // Mobile Leaderboard - "300x50" => 0.0, // Mobile Banner (alternative) - "320x100" => 0.0, // Large Mobile Banner - "320x480" => 0.0, // Mobile Interstitial Portrait - "480x320" => 0.0, // Mobile Interstitial Landscape -}; - -/// Format dimensions as lookup key. -#[inline] -fn size_key(w: i64, h: i64) -> String { - format!("{}x{}", w, h) -} + (160, 600), // Wide Skyscraper + (300, 50), // Mobile Banner (alternative) + (300, 250), // Medium Rectangle + (300, 600), // Half Page + (320, 50), // Mobile Leaderboard + (320, 100), // Large Mobile Banner + (320, 480), // Mobile Interstitial Portrait + (336, 280), // Large Rectangle + (468, 60), // Banner + (480, 320), // Mobile Interstitial Landscape + (728, 90), // Leaderboard + (970, 90), // Large Leaderboard + (970, 250), // Billboard +]; /// Check if dimensions match a standard ad size. pub fn is_standard_size(w: i64, h: i64) -> bool { - SIZE_MAP.contains_key(size_key(w, h).as_str()) + STANDARD_SIZES.iter().any(|&(sw, sh)| sw == w && sh == h) } /// Returns an iterator over all standard ad sizes as (width, height) tuples. /// Useful for generating test fixtures or validating external configurations. pub fn standard_sizes() -> impl Iterator { - let mut sizes: Vec<(i64, i64)> = SIZE_MAP - .keys() - .filter_map(|key| { - let (w_str, h_str) = key.split_once('x')?; - let w = w_str.parse::().ok()?; - let h = h_str.parse::().ok()?; - Some((w, h)) - }) - .collect(); - sizes.sort_unstable(); - debug_assert_eq!( - sizes.len(), - SIZE_MAP.len(), - "SIZE_MAP contains invalid size keys" - ); - sizes.into_iter() + STANDARD_SIZES.iter().copied() } fn new_id() -> String { @@ -99,7 +74,7 @@ pub fn standard_or_default((w, h): (i64, i64)) -> (i64, i64) { /// Build an OpenRTB bid response for the given request. /// /// - Enforces standard ad sizes (non-standard sizes default to 300x250) -/// - Uses a fixed CPM price ($0.01) +/// - Uses a fixed CPM price ($0.20) /// - Embeds signature verification status, the original request, and a preview /// of the response as HTML comments in each creative /// - The signature badge is rendered inside the creative via the `sig` query param @@ -231,7 +206,7 @@ pub fn decode_aps_price(encoded: &str) -> Option { /// Build APS TAM response from an APS bid request matching real Amazon API format. /// /// This function generates mock bids for all slots with standard sizes: -/// - Fixed bid price of $0.01 CPM +/// - Fixed bid price of $0.20 CPM /// - 100% fill rate for standard sizes /// - Returns contextual format matching real Amazon APS API /// - No creative HTML (APS doesn't return adm field) @@ -572,7 +547,7 @@ mod tests { pub_id: "test".to_string(), slots: vec![ApsSlot { slot_id: "slot1".to_string(), - sizes: vec![[300, 250]], // CPM is fixed at $0.01 + sizes: vec![[300, 250]], // CPM is fixed at $0.20 slot_name: None, }], page_url: None, diff --git a/crates/mocktioneer-core/src/routes.rs b/crates/mocktioneer-core/src/routes.rs index a81821c..6c9c0db 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -493,7 +493,7 @@ pub async fn handle_click(ValidatedQuery(params): ValidatedQuery Date: Mon, 23 Mar 2026 16:58:01 -0500 Subject: [PATCH 09/13] Align docs with FIXED_BID_CPM of $0.20 Update all references from $0.01 to $0.20 across docs, CLAUDE.md, and inline code comments to match the actual constant value. --- CLAUDE.md | 2 +- docs/api/aps-bid.md | 4 ++-- docs/api/index.md | 2 +- docs/api/openrtb-auction.md | 4 ++-- docs/guide/what-is-mocktioneer.md | 2 +- docs/integrations/index.md | 4 ++-- docs/integrations/prebid-server.md | 6 +++--- docs/integrations/prebidjs.md | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8317e3..743d408 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,7 @@ through `render.rs`. Do not inline ad markup in handlers. ## Key Constants -- `FIXED_BID_CPM: f64 = 0.01` — fixed price for all Mocktioneer-generated bids +- `FIXED_BID_CPM: f64 = 0.20` — fixed price for all Mocktioneer-generated bids - `SIZE_MAP` — 13 standard IAB sizes via `phf::Map` (300x250, 728x90, 320x50, etc.) ## CI Gates diff --git a/docs/api/aps-bid.md b/docs/api/aps-bid.md index 3971517..ee05296 100644 --- a/docs/api/aps-bid.md +++ b/docs/api/aps-bid.md @@ -115,8 +115,8 @@ The price encoding differs between real APS and Mocktioneer: Decode Mocktioneer prices: ```bash -echo "MC4wMQ==" | base64 -d -# Output: 0.01 +echo "MC4yMA==" | base64 -d +# Output: 0.20 ``` ## Examples diff --git a/docs/api/index.md b/docs/api/index.md index 7a12a55..843d3a4 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -58,7 +58,7 @@ All responses include CORS headers (`Access-Control-Allow-Origin: *`, etc.). See ## Supported Ad Sizes {#supported-sizes} -Mocktioneer supports these standard IAB sizes. All auction bids use a fixed price of `$0.01` CPM. +Mocktioneer supports these standard IAB sizes. All auction bids use a fixed price of `$0.20` CPM. | Size | Name | | ------- | ----------------------------- | diff --git a/docs/api/openrtb-auction.md b/docs/api/openrtb-auction.md index 6efdb4b..8460d68 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -103,7 +103,7 @@ Size is determined in this order: { "id": "019abc123", "impid": "imp-1", - "price": 0.01, + "price": 0.20, "adm": "", "adomain": ["example.com"], "crid": "mocktioneer-imp-1", @@ -139,7 +139,7 @@ Size is determined in this order: ## Pricing -Mocktioneer returns a fixed bid price of `$0.01` CPM for auction responses. +Mocktioneer returns a fixed bid price of `$0.20` CPM for auction responses. If `imp[].ext.mocktioneer.bid` is present, it is ignored. diff --git a/docs/guide/what-is-mocktioneer.md b/docs/guide/what-is-mocktioneer.md index 53e4a2d..a02c7f6 100644 --- a/docs/guide/what-is-mocktioneer.md +++ b/docs/guide/what-is-mocktioneer.md @@ -48,7 +48,7 @@ Mocktioneer provides: | --------------- | --------------------------------------------- | | Multi-platform | Runs on Fastly, Cloudflare, and native Axum | | Manifest-driven | Single `edgezero.toml` configures everything | -| Fixed pricing | Always returns `$0.01` CPM for generated bids | +| Fixed pricing | Always returns `$0.20` CPM for generated bids | | Standard sizes | Supports common IAB ad sizes | | Cookie tracking | Optional pixel tracking with `mtkid` cookie | | CORS enabled | Works with browser-based clients | diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 7633273..bb4ef1f 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -48,7 +48,7 @@ Mocktioneer acts as a drop-in replacement for real bidders during development an - Same request always produces same response - No flaky tests due to bidder variability -- Fixed $0.01 bids for predictable floor testing +- Fixed $0.20 bids for predictable floor testing ### No External Dependencies @@ -148,7 +148,7 @@ Test price handling and floor logic: } ``` -Mocktioneer always bids at `$0.01` CPM, so floors above that value should reject bids. +Mocktioneer always bids at `$0.20` CPM, so floors above that value should reject bids. ### Error Handling diff --git a/docs/integrations/prebid-server.md b/docs/integrations/prebid-server.md index 3de014b..50497cd 100644 --- a/docs/integrations/prebid-server.md +++ b/docs/integrations/prebid-server.md @@ -91,7 +91,7 @@ Override the endpoint for specific requests: | ---------- | ------------------ | ------ | ------------------------- | | `endpoint` | `imp[].ext.bidder` | string | Override auction endpoint | -Mocktioneer always returns a fixed bid price of `$0.01` CPM. +Mocktioneer always returns a fixed bid price of `$0.20` CPM. ## Response Handling @@ -106,7 +106,7 @@ Prebid Server processes Mocktioneer responses like any other bidder: { "id": "019abc-mocktioneer", "impid": "imp-1", - "price": 0.01, + "price": 0.20, "adm": "", "crid": "019abc-mocktioneer", "w": 300, @@ -273,7 +273,7 @@ Include Mocktioneer alongside real bidders: ### Price Floor Testing -Test floor enforcement (Mocktioneer bids at `$0.01` which will be below most floors): +Test floor enforcement (Mocktioneer bids at `$0.20` which will be below most floors): ```json { diff --git a/docs/integrations/prebidjs.md b/docs/integrations/prebidjs.md index 2e301dd..69d30cd 100644 --- a/docs/integrations/prebidjs.md +++ b/docs/integrations/prebidjs.md @@ -74,7 +74,7 @@ params: { | ---------- | ------ | -------- | --------------------------- | | `endpoint` | string | No | Custom auction endpoint URL | -Mocktioneer always returns a fixed bid price of `$0.01` CPM. +Mocktioneer always returns a fixed bid price of `$0.20` CPM. ## Example Page From 14662fddd726b62c08a55abf257f0d8f61079a03 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 31 Mar 2026 16:07:23 -0500 Subject: [PATCH 10/13] format docs --- docs/api/openrtb-auction.md | 2 +- docs/integrations/prebid-server.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/openrtb-auction.md b/docs/api/openrtb-auction.md index 8460d68..f74c4c8 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -103,7 +103,7 @@ Size is determined in this order: { "id": "019abc123", "impid": "imp-1", - "price": 0.20, + "price": 0.2, "adm": "", "adomain": ["example.com"], "crid": "mocktioneer-imp-1", diff --git a/docs/integrations/prebid-server.md b/docs/integrations/prebid-server.md index 50497cd..c4c74c8 100644 --- a/docs/integrations/prebid-server.md +++ b/docs/integrations/prebid-server.md @@ -106,7 +106,7 @@ Prebid Server processes Mocktioneer responses like any other bidder: { "id": "019abc-mocktioneer", "impid": "imp-1", - "price": 0.20, + "price": 0.2, "adm": "", "crid": "019abc-mocktioneer", "w": 300, From a95717a5c62cbf89e1402180ae3baf0f690107f9 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 1 Apr 2026 19:12:08 -0500 Subject: [PATCH 11/13] Address PR review feedback --- CLAUDE.md | 2 +- crates/mocktioneer-core/src/routes.rs | 2 +- crates/mocktioneer-core/src/verification.rs | 2 -- crates/mocktioneer-core/tests/aps_endpoints.rs | 2 +- docs/api/aps-bid.md | 8 ++++---- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 743d408..42852bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ through `render.rs`. Do not inline ad markup in handlers. ## Key Constants - `FIXED_BID_CPM: f64 = 0.20` — fixed price for all Mocktioneer-generated bids -- `SIZE_MAP` — 13 standard IAB sizes via `phf::Map` (300x250, 728x90, 320x50, etc.) +- `STANDARD_SIZES` — 13 standard IAB sizes as a const array (300x250, 728x90, 320x50, etc.) ## CI Gates diff --git a/crates/mocktioneer-core/src/routes.rs b/crates/mocktioneer-core/src/routes.rs index 6c9c0db..203ab62 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -902,7 +902,7 @@ mod tests { let slots = contextual.get("slots").unwrap().as_array().unwrap(); assert_eq!(slots.len(), 1); - // Check slot details (should select 970x250 with highest CPM from [728x90, 970x250]) + // Check slot details (should select 970x250 with largest area from [728x90, 970x250]) let slot = &slots[0]; assert_eq!( slot.get("slotID").unwrap().as_str().unwrap(), diff --git a/crates/mocktioneer-core/src/verification.rs b/crates/mocktioneer-core/src/verification.rs index 07656f4..cb6d443 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -63,8 +63,6 @@ pub enum VerificationError { SignatureVerificationFailed, #[error("HTTP error: {0}")] HttpError(String), - #[error("No domain for JWKS verification")] - NoJwksDomain, } async fn fetch_jwks(ctx: &RequestContext, domain: &str) -> Result { diff --git a/crates/mocktioneer-core/tests/aps_endpoints.rs b/crates/mocktioneer-core/tests/aps_endpoints.rs index 6bafc8d..c091ed7 100644 --- a/crates/mocktioneer-core/tests/aps_endpoints.rs +++ b/crates/mocktioneer-core/tests/aps_endpoints.rs @@ -194,7 +194,7 @@ fn test_aps_bid_multiple_sizes_per_slot() { let resp = build_aps_response(&req, "mocktioneer.test"); - // Should bid on the highest CPM size (970x250 = $4.20 > 728x90 = $3.00) + // Should bid on the largest area size (970x250 = 242500 > 728x90 = 65520) assert_eq!(resp.contextual.slots.len(), 1); assert_eq!(resp.contextual.slots[0].size, "970x250"); } diff --git a/docs/api/aps-bid.md b/docs/api/aps-bid.md index ee05296..a621c61 100644 --- a/docs/api/aps-bid.md +++ b/docs/api/aps-bid.md @@ -60,8 +60,8 @@ The response matches the real Amazon APS API format with a `contextual` wrapper: "targeting": ["amzniid", "amznp", "amznsz", "amznbid", "amznactt"], "meta": ["slotID", "mediaType", "size"], "amzniid": "019b7f82e8de7e139d6d6a593171e7a0", - "amznbid": "MC4wMQ==", - "amznp": "MC4wMQ==", + "amznbid": "MC4y", + "amznp": "MC4y", "amznsz": "970x250", "amznactt": "OPEN" } @@ -115,8 +115,8 @@ The price encoding differs between real APS and Mocktioneer: Decode Mocktioneer prices: ```bash -echo "MC4yMA==" | base64 -d -# Output: 0.20 +echo "MC4y" | base64 -d +# Output: 0.2 ``` ## Examples From 7b3b7592385eefd42b35858c40aca4c2f77fb6ac Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 3 Apr 2026 12:48:44 -0500 Subject: [PATCH 12/13] Fix mismatched hosts in OpenRTB example and clarify floor enforcement docs --- docs/api/openrtb-auction.md | 2 +- docs/integrations/index.md | 2 +- docs/integrations/prebid-server.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api/openrtb-auction.md b/docs/api/openrtb-auction.md index f74c4c8..9f17dc0 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -55,7 +55,7 @@ Content-Type: application/json "version": "1.1", "signature": "base64-encoded-signature", "kid": "key-id", - "request_host": "publisher.example", + "request_host": "example.com", "request_scheme": "https", "ts": 1706900000000 } diff --git a/docs/integrations/index.md b/docs/integrations/index.md index bb4ef1f..e18acec 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -148,7 +148,7 @@ Test price handling and floor logic: } ``` -Mocktioneer always bids at `$0.20` CPM, so floors above that value should reject bids. +Mocktioneer always bids at a fixed `$0.20` CPM and does not evaluate `bidfloor` itself. Use this predictable price to test that your SSP or mediation layer correctly filters bids below the floor threshold. ### Error Handling diff --git a/docs/integrations/prebid-server.md b/docs/integrations/prebid-server.md index c4c74c8..73c3ccf 100644 --- a/docs/integrations/prebid-server.md +++ b/docs/integrations/prebid-server.md @@ -273,7 +273,7 @@ Include Mocktioneer alongside real bidders: ### Price Floor Testing -Test floor enforcement (Mocktioneer bids at `$0.20` which will be below most floors): +Test that your SSP enforces floors correctly (Mocktioneer always bids at a fixed `$0.20` and does not evaluate `bidfloor` itself): ```json { From f744a9aaee4b4e03610fabf5329f27b499cf693b Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 16 Apr 2026 16:51:03 -0500 Subject: [PATCH 13/13] Address round 5 review: stale docs and redundant domain params - CLAUDE.md: update auction.rs module description (no more CPM math), drop phf from Dependencies Philosophy (removed in 5a8fc3e), and note the signature timestamp freshness carve-out in the Determinism rule. - auction.rs: fix stale base64 example in encode_aps_price doc comment to reflect FIXED_BID_CPM = 0.20 (MC4y -> 0.2). - verification.rs + routes.rs: collapse redundant domain/site_domain params on verify_request_id_signature into a single site_domain arg. --- CLAUDE.md | 8 +++++--- crates/mocktioneer-core/src/auction.rs | 2 +- crates/mocktioneer-core/src/routes.rs | 1 - crates/mocktioneer-core/src/verification.rs | 13 ++----------- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42852bc..7073360 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,8 @@ through `render.rs`. Do not inline ad markup in handlers. Avoid unnecessary refactoring, docstrings on untouched code, or premature abstractions. - **No direct `http` crate imports** — use `edgezero_core` re-exports. - **Determinism**: no randomness, no time-dependent pricing. Same input always - produces the same output. + produces the same output. Exception: signature timestamp freshness + validation uses the wall clock — required for replay prevention. ## Module Structure (mocktioneer-core/src/) @@ -141,7 +142,7 @@ through `render.rs`. Do not inline ad markup in handlers. | ----------------- | ------------------------------------------------ | | `lib.rs` | App bootstrapper via `edgezero_core::app!` macro | | `routes.rs` | All HTTP handlers + query struct validation | -| `auction.rs` | Size pricing, CPM calculation, standard sizes | +| `auction.rs` | Fixed-price bidding, standard size validation | | `openrtb.rs` | OpenRTB 2.x request/response types | | `aps.rs` | APS TAM API types & bid handling | | `mediation.rs` | Multi-bidder mediation logic | @@ -302,7 +303,7 @@ Custom commands live in `.claude/commands/`: - Minimal, carefully curated for WASM compatibility. - `Cargo.lock` is committed for reproducible builds. - Key crates: `edgezero-*` (framework), `serde`/`serde_json` (serialization), - `validator` (input validation), `handlebars` (templates), `phf` (static maps), + `validator` (input validation), `handlebars` (templates), `ed25519-dalek` (signatures), `uuid` (request IDs). - Optional `.cargo/config.toml.local` for local edgezero development without re-publishing. @@ -319,4 +320,5 @@ Custom commands live in `.claude/commands/`: - Don't commit without running `cargo test` first. - Don't skip `cargo fmt` and `cargo clippy` — CI will reject the PR. - Don't introduce non-deterministic behavior (randomness, time-dependent logic). + Signature timestamp freshness is the lone carve-out for replay prevention. - Don't include `Co-Authored-By` trailers, "Generated with" footers, or any AI bylines in commits or PR bodies. diff --git a/crates/mocktioneer-core/src/auction.rs b/crates/mocktioneer-core/src/auction.rs index 24588cb..d1c2c25 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -183,7 +183,7 @@ pub fn build_openrtb_response( /// /// Note: Real Amazon APS uses proprietary encoding that cannot be decoded without Amazon's keys. /// Our mock uses transparent base64 encoding that CAN be decoded for testing/debugging purposes. -/// Example: `echo "Mi41MA==" | base64 -d` → `2.50` +/// Example: `echo "MC4y" | base64 -d` → `0.2` fn encode_aps_price(price: f64) -> String { use base64::{engine::general_purpose::STANDARD, Engine as _}; diff --git a/crates/mocktioneer-core/src/routes.rs b/crates/mocktioneer-core/src/routes.rs index 203ab62..0dec516 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -247,7 +247,6 @@ pub async fn handle_openrtb_auction( &req.id, req.ext.as_ref(), domain, - domain, ) .await { diff --git a/crates/mocktioneer-core/src/verification.rs b/crates/mocktioneer-core/src/verification.rs index cb6d443..9a8a55c 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -297,7 +297,6 @@ pub async fn verify_request_id_signature( ctx: &RequestContext, request_id: &str, ext: Option<&serde_json::Value>, - domain: &str, site_domain: &str, ) -> Result { let ext_obj = ext.and_then(|e| e.get("trusted_server")).ok_or_else(|| { @@ -361,12 +360,12 @@ pub async fn verify_request_id_signature( "Signature verification requested: id={}, kid={}, domain={:?}, version={}, ts={}", request_id, key_id, - domain, + site_domain, version, timestamp ); - let jwks = get_cached_jwks(ctx, domain).await?; + let jwks = get_cached_jwks(ctx, site_domain).await?; let public_key = find_public_key(&jwks, key_id)?; verify_ed25519_signature(public_key, signature, &payload)?; @@ -407,7 +406,6 @@ mod tests { request_id, Some(&ext), "example.com", - "example.com", )); assert!(matches!( result.unwrap_err(), @@ -431,7 +429,6 @@ mod tests { request_id, Some(&ext), "example.com", - "example.com", )); assert!(matches!( result.unwrap_err(), @@ -453,7 +450,6 @@ mod tests { request_id, Some(&ext), "example.com", - "example.com", )); assert!(matches!( result.unwrap_err(), @@ -472,7 +468,6 @@ mod tests { request_id, None, "example.com", - "example.com", )); assert!(matches!( result.unwrap_err(), @@ -501,7 +496,6 @@ mod tests { request_id, Some(&ext), "example.com", - "example.com", )); assert!(matches!( result.unwrap_err(), @@ -607,7 +601,6 @@ mod tests { "test-id", Some(&ext), "example.com", - "example.com", )); let err = result.unwrap_err().to_string(); assert!(err.contains("does not match site.domain")); @@ -634,7 +627,6 @@ mod tests { "test-id", Some(&ext), "example.com", - "example.com", )); let err = result.unwrap_err(); assert!(matches!(err, VerificationError::InvalidSignature(_))); @@ -662,7 +654,6 @@ mod tests { "test-id", Some(&ext), "example.com", - "example.com", )); let err = result.unwrap_err(); assert!(matches!(err, VerificationError::InvalidSignature(_)));