diff --git a/CLAUDE.md b/CLAUDE.md index f405629..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 | @@ -150,9 +151,8 @@ 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 -- `SIZE_MAP` — 13 standard IAB sizes via `phf::Map` (300x250, 728x90, 320x50, etc.) +- `FIXED_BID_CPM: f64 = 0.20` — fixed price for all Mocktioneer-generated bids +- `STANDARD_SIZES` — 13 standard IAB sizes as a const array (300x250, 728x90, 320x50, etc.) ## CI Gates @@ -303,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. @@ -320,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/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 50d9b4e..d1c2c25 100644 --- a/crates/mocktioneer-core/src/auction.rs +++ b/crates/mocktioneer-core/src/auction.rs @@ -3,83 +3,43 @@ 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 serde_json::json; 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.20; -/// 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. -/// 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" => 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 - // 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 -}; - -/// 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()) -} - -/// 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 - }) + 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 { @@ -114,8 +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 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.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 @@ -131,16 +90,23 @@ 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 + // 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); + .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 + ); + } - // 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 +118,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 +155,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(); @@ -229,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 _}; @@ -252,9 +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: -/// - 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.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) @@ -263,7 +215,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() @@ -271,15 +223,15 @@ 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, 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 +241,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); @@ -492,7 +445,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 { @@ -511,18 +464,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")); } // ======================================================================== @@ -576,12 +522,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, @@ -592,7 +538,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] @@ -601,7 +547,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.20 slot_name: None, }], page_url: None, @@ -613,7 +559,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/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/routes.rs b/crates/mocktioneer-core/src/routes.rs index 24d4d0b..0dec516 100644 --- a/crates/mocktioneer-core/src/routes.rs +++ b/crates/mocktioneer-core/src/routes.rs @@ -492,28 +492,25 @@ 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(); @@ -904,7 +901,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(), @@ -1002,6 +999,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 f4819b3..9a8a55c 100644 --- a/crates/mocktioneer-core/src/verification.rs +++ b/crates/mocktioneer-core/src/verification.rs @@ -5,12 +5,16 @@ 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"; + +/// 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 { @@ -33,6 +37,19 @@ 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, + kid: &'a str, + host: &'a str, + scheme: &'a str, + id: &'a str, + ts: u64, +} + static JWKS_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -46,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 { @@ -59,7 +74,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() @@ -110,20 +124,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,37 +211,163 @@ 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: impl FnOnce() -> VerificationError, +) -> Result<&'a str, VerificationError> { + ext_obj + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(missing_error) +} + +fn required_ext_u64( + ext_obj: &serde_json::Value, + field: &str, + missing_error: impl FnOnce() -> VerificationError, +) -> Result { + ext_obj + .get(field) + .and_then(serde_json::Value::as_u64) + .ok_or_else(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())) +} + +/// 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); + + 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, + 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()) })?; - 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 signature = required_ext_str(ext_obj, "signature", || { + 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(|| { + 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()) + })?; + + // 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_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 site.domain '{}'", + request_host, site_domain + ))); + } + + // 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)?; + + 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 + 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, request_id)?; + verify_ed25519_signature(public_key, signature, &payload)?; Ok(key_id.to_string()) } @@ -338,6 +475,69 @@ 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": now_ms + } + }); + + 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", + 1706900000000, + "1.1", + ) + .expect("payload"); + + assert_eq!( + payload, + "{\"version\":\"1.1\",\"kid\":\"kid-abc\",\"host\":\"publisher.example\",\"scheme\":\"https\",\"id\":\"req-123\",\"ts\":1706900000000}" + ); + } + + #[test] + fn build_signing_payload_rejects_unknown_version() { + let result = build_signing_payload( + "req-123", + "kid-abc", + "publisher.example", + "https", + 1706900000000, + "1.0", + ); + + assert!(matches!( + result.unwrap_err(), + VerificationError::InvalidSignature(_) + )); + } + #[test] fn find_public_key_found() { let jwks = JwksResponse { @@ -380,4 +580,142 @@ 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", + )); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not match site.domain")); + } + + #[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", + )); + let err = result.unwrap_err(); + assert!(matches!(err, VerificationError::InvalidSignature(_))); + assert!(err.to_string().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", + )); + let err = result.unwrap_err(); + assert!(matches!(err, VerificationError::InvalidSignature(_))); + assert!(err.to_string().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 + )); + } + + #[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"); + } } 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/crates/mocktioneer-core/tests/aps_endpoints.rs b/crates/mocktioneer-core/tests/aps_endpoints.rs index fb606a0..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"); } @@ -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..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": "NC4yMA==", - "amznp": "NC4yMA==", + "amznbid": "MC4y", + "amznp": "MC4y", "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 "MC4y" | base64 -d +# Output: 0.2 ``` ## Examples diff --git a/docs/api/index.md b/docs/api/index.md index 0999d82..843d3a4 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.20` 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 211d82e..9f17dc0 100644 --- a/docs/api/openrtb-auction.md +++ b/docs/api/openrtb-auction.md @@ -47,18 +47,17 @@ Content-Type: application/json { "w": 300, "h": 250 }, { "w": 320, "h": 50 } ] - }, - "ext": { - "mocktioneer": { - "bid": 2.5 - } } } ], "ext": { "trusted_server": { + "version": "1.1", "signature": "base64-encoded-signature", - "kid": "key-id" + "kid": "key-id", + "request_host": "example.com", + "request_scheme": "https", + "ts": 1706900000000 } } } @@ -66,20 +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 | -| `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 | -| `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 @@ -101,8 +103,8 @@ Size is determined in this order: { "id": "019abc123", "impid": "imp-1", - "price": 2.5, - "adm": "", + "price": 0.2, + "adm": "", "adomain": ["example.com"], "crid": "mocktioneer-imp-1", "w": 300, @@ -135,34 +137,11 @@ Size is determined in this order: | `seatbid[].bid[].mtype` | integer | Media type (1 = banner) | | `cur` | string | Currency (USD) | -## Price Override - -Override the bid price using the `ext.mocktioneer.bid` field: - -```json -{ - "id": "test", - "imp": [ - { - "id": "1", - "banner": { "w": 300, "h": 250 }, - "ext": { - "mocktioneer": { - "bid": 5.0 - } - } - } - ] -} -``` - -The creative will display this bid amount. +## Pricing -## Default Pricing +Mocktioneer returns a fixed bid price of `$0.20` CPM for auction responses. -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 @@ -195,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) @@ -249,7 +213,24 @@ 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 signed payload is canonical JSON: + +```json +{ + "version": "1.1", + "kid": "...", + "host": "...", + "scheme": "https", + "id": "...", + "ts": 1706900000000 +} +``` -The JWKS is fetched from `http://{site.domain}/.well-known/trusted-server.json`. Verification failures are logged but don't reject the request. +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..a02c7f6 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.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 | ## How It Works 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/index.md b/docs/integrations/index.md index b450423..e18acec 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.20 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 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 Test error scenarios: diff --git a/docs/integrations/prebid-server.md b/docs/integrations/prebid-server.md index 8a5f062..73c3ccf 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.20` 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.2, "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 that your SSP enforces floors correctly (Mocktioneer always bids at a fixed `$0.20` and does not evaluate `bidfloor` itself): ```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..69d30cd 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.20` 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', 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'); } });