From f5a984a608e2081c3b6c650e7d0d42ff99489af1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 11:15:01 +0530 Subject: [PATCH 01/22] Add dual-path entry point with feature-flag dispatch and legacy_main extraction --- .../trusted-server-adapter-fastly/src/app.rs | 11 +++ .../trusted-server-adapter-fastly/src/main.rs | 87 +++++++++++++++++-- .../src/middleware.rs | 1 + 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 crates/trusted-server-adapter-fastly/src/app.rs create mode 100644 crates/trusted-server-adapter-fastly/src/middleware.rs diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs new file mode 100644 index 000000000..076dd6c89 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -0,0 +1,11 @@ +// Stub implementation — full routing wired in Task 4. +use edgezero_core::app::Hooks; +use edgezero_core::router::RouterService; + +pub struct TrustedServerApp; + +impl Hooks for TrustedServerApp { + fn routes() -> RouterService { + RouterService::builder().build() + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 2ae86ff50..2f342bc77 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -30,24 +30,79 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; +mod app; mod error; mod logging; mod management_api; +mod middleware; mod platform; +use crate::app::TrustedServerApp; +use edgezero_core::app::Hooks as _; use crate::error::to_error_response; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; -#[fastly::main] -fn main(mut req: FastlyRequest) -> Result { - logging::init_logger(); +/// Returns `true` if the raw config-store value represents an enabled flag. +/// +/// Accepted values (after whitespace trimming): `"true"` and `"1"`. +/// All other values, including the empty string, are treated as disabled. +fn parse_edgezero_flag(value: &str) -> bool { + let v = value.trim(); + v == "true" || v == "1" +} - // Keep the health probe independent from settings loading and routing so - // readiness checks still get a cheap liveness response during startup. +/// Reads the `edgezero_enabled` key from the `"trusted_server_config"` Fastly +/// ConfigStore. +/// +/// Returns `Err` on any store open or key-read failure, so callers should use +/// `.unwrap_or(false)` to ensure the legacy path is the safe default. +/// +/// # Errors +/// +/// - [`fastly::Error`] if the config store cannot be opened or the key cannot be read. +fn is_edgezero_enabled() -> Result { + let store = fastly::ConfigStore::try_open("trusted_server_config") + .map_err(|e| fastly::Error::msg(format!("failed to open config store: {e}")))?; + let value = store + .try_get("edgezero_enabled") + .map_err(|e| fastly::Error::msg(format!("failed to read edgezero_enabled: {e}")))? + .unwrap_or_default(); + Ok(parse_edgezero_flag(&value)) +} + +#[fastly::main] +fn main(req: FastlyRequest) -> Result { + // Health probe bypasses routing, settings, and app construction — cheap liveness signal. if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { return Ok(FastlyResponse::from_status(200).with_body_text_plain("ok")); } + logging::init_logger(); + + // Safe default: if the flag cannot be read (store unavailable, key missing), + // fall back to the legacy path to avoid accidentally routing through an + // untested EdgeZero path. + if is_edgezero_enabled().unwrap_or(false) { + let app = TrustedServerApp::build_app(); + edgezero_adapter_fastly::dispatch(&app, req) + } else { + legacy_main(req) + } +} + +/// Handles a request using the original Fastly-native entry point. +/// +/// Preserves identical semantics to the pre-PR14 `main()`. Called when +/// the `edgezero_enabled` config flag is absent or `false`. +/// +/// The thin fastly↔http conversion layer (via `compat::from_fastly_request` / +/// `compat::to_fastly_response`) lives here in the adapter crate. `compat.rs` +/// will be deleted in PR 15 once this legacy path is retired. +/// +/// # Errors +/// +/// Propagates [`fastly::Error`] from the Fastly SDK. +fn legacy_main(mut req: FastlyRequest) -> Result { let settings = match get_settings() { Ok(s) => s, Err(e) => { @@ -258,3 +313,25 @@ fn http_error_response(report: &Report) -> HttpResponse { ); response } + +#[cfg(test)] +mod tests { + use super::parse_edgezero_flag; + + #[test] + fn parses_true_flag_values() { + assert!(parse_edgezero_flag("true"), "should parse 'true'"); + assert!(parse_edgezero_flag("1"), "should parse '1'"); + assert!(parse_edgezero_flag(" true "), "should trim whitespace"); + assert!(parse_edgezero_flag(" 1 "), "should trim whitespace around '1'"); + } + + #[test] + fn rejects_non_true_flag_values() { + assert!(!parse_edgezero_flag("false"), "should not parse 'false'"); + assert!(!parse_edgezero_flag(""), "should not parse empty string"); + assert!(!parse_edgezero_flag(" "), "should not parse whitespace-only"); + assert!(!parse_edgezero_flag("yes"), "should not parse 'yes'"); + assert!(!parse_edgezero_flag("TRUE"), "should not parse uppercase 'TRUE'"); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs new file mode 100644 index 000000000..12b3ba6f0 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -0,0 +1 @@ +// Stub — full implementation in Tasks 2 and 3. From a81b1d6fc41f9427f96a2c45da4b328f7d31dc92 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 11:19:17 +0530 Subject: [PATCH 02/22] Fix clippy doc_markdown, fmt, and add warn log on flag-read failure --- .../trusted-server-adapter-fastly/src/main.rs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 2f342bc77..5ba9e497f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -38,9 +38,9 @@ mod middleware; mod platform; use crate::app::TrustedServerApp; -use edgezero_core::app::Hooks as _; use crate::error::to_error_response; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; +use edgezero_core::app::Hooks as _; /// Returns `true` if the raw config-store value represents an enabled flag. /// @@ -52,7 +52,7 @@ fn parse_edgezero_flag(value: &str) -> bool { } /// Reads the `edgezero_enabled` key from the `"trusted_server_config"` Fastly -/// ConfigStore. +/// [`ConfigStore`]. /// /// Returns `Err` on any store open or key-read failure, so callers should use /// `.unwrap_or(false)` to ensure the legacy path is the safe default. @@ -82,7 +82,10 @@ fn main(req: FastlyRequest) -> Result { // Safe default: if the flag cannot be read (store unavailable, key missing), // fall back to the legacy path to avoid accidentally routing through an // untested EdgeZero path. - if is_edgezero_enabled().unwrap_or(false) { + if is_edgezero_enabled().unwrap_or_else(|e| { + log::warn!("failed to read edgezero_enabled flag, falling back to legacy path: {e}"); + false + }) { let app = TrustedServerApp::build_app(); edgezero_adapter_fastly::dispatch(&app, req) } else { @@ -323,15 +326,24 @@ mod tests { assert!(parse_edgezero_flag("true"), "should parse 'true'"); assert!(parse_edgezero_flag("1"), "should parse '1'"); assert!(parse_edgezero_flag(" true "), "should trim whitespace"); - assert!(parse_edgezero_flag(" 1 "), "should trim whitespace around '1'"); + assert!( + parse_edgezero_flag(" 1 "), + "should trim whitespace around '1'" + ); } #[test] fn rejects_non_true_flag_values() { assert!(!parse_edgezero_flag("false"), "should not parse 'false'"); assert!(!parse_edgezero_flag(""), "should not parse empty string"); - assert!(!parse_edgezero_flag(" "), "should not parse whitespace-only"); + assert!( + !parse_edgezero_flag(" "), + "should not parse whitespace-only" + ); assert!(!parse_edgezero_flag("yes"), "should not parse 'yes'"); - assert!(!parse_edgezero_flag("TRUE"), "should not parse uppercase 'TRUE'"); + assert!( + !parse_edgezero_flag("TRUE"), + "should not parse uppercase 'TRUE'" + ); } } From 7d2f0592d6c612a19f0b647c3143e058933df2da Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 11:24:58 +0530 Subject: [PATCH 03/22] Add FinalizeResponseMiddleware and AuthMiddleware with golden header-precedence test --- .../src/middleware.rs | 249 +++++++++++++++++- 1 file changed, 248 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 12b3ba6f0..dcd27abb2 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -1 +1,248 @@ -// Stub — full implementation in Tasks 2 and 3. +//! Middleware implementations for the dual-path entry point. +//! +//! Provides two middleware types that mirror the finalization and auth logic +//! from the legacy [`crate::finalize_response`] and [`crate::route_request`]: +//! +//! - [`FinalizeResponseMiddleware`] — geo lookup and standard TS header injection +//! - [`AuthMiddleware`] — basic-auth enforcement via [`enforce_basic_auth`] +//! +//! Registration order in [`crate::app`]: `FinalizeResponseMiddleware` outermost, +//! then `AuthMiddleware`. This ensures auth-rejected responses also receive the +//! standard TS headers before being returned to the client. + +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderName, HeaderValue, Response}; +use edgezero_core::middleware::{Middleware, Next}; +use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::constants::{ + ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, + HEADER_X_TS_ENV, HEADER_X_TS_VERSION, +}; +use trusted_server_core::geo::GeoInfo; +use trusted_server_core::platform::PlatformGeo as _; +use trusted_server_core::settings::Settings; + +use crate::platform::FastlyPlatformGeo; + +// --------------------------------------------------------------------------- +// FinalizeResponseMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: performs geo lookup and injects all standard TS response headers. +/// +/// Registered first in the middleware chain so that it wraps all inner middleware +/// (including [`AuthMiddleware`]) and the handler. This guarantees every outgoing +/// response — including auth-rejected ones — carries a consistent set of headers. +/// +/// # Header precedence +/// +/// Headers are written in this order (last write wins): +/// 1. Geo headers (or `X-Geo-Info-Available: false` when geo is unavailable) +/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 4. Operator-configured `settings.response_headers` (can override any managed header) +// Used in Task 4 when app.rs registers the middleware chain. +#[allow(dead_code)] +pub struct FinalizeResponseMiddleware { + settings: Arc, +} + +impl FinalizeResponseMiddleware { + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. + #[allow(dead_code)] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for FinalizeResponseMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + + let geo_info = FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); + + let mut response = next.run(ctx).await?; + + apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); + + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AuthMiddleware +// --------------------------------------------------------------------------- + +/// Inner middleware: enforces basic-auth before the handler runs. +/// +/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). +/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. +/// - `Err(report)` → internal error; log and return [`EdgeError::internal`]. +/// +/// # Errors +/// +/// Returns [`EdgeError::internal`] when [`enforce_basic_auth`] returns an error report. +// Used in Task 4 when app.rs registers the middleware chain. +#[allow(dead_code)] +pub struct AuthMiddleware { + settings: Arc, +} + +impl AuthMiddleware { + /// Creates a new [`AuthMiddleware`] with the given settings. + #[allow(dead_code)] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AuthMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + match enforce_basic_auth(&self.settings, ctx.request()) { + Ok(Some(response)) => return Ok(response), + Ok(None) => {} + Err(report) => { + log::error!("auth check failed: {:?}", report); + return Err(EdgeError::internal(std::io::Error::other(format!( + "auth check failed: {report}" + )))); + } + } + + next.run(ctx).await + } +} + +// --------------------------------------------------------------------------- +// apply_finalize_headers — extracted for unit testing +// --------------------------------------------------------------------------- + +/// Applies all standard Trusted Server response headers to the given response. +/// +/// Mirrors [`crate::finalize_response`] exactly, operating on [`Response`] from +/// `edgezero_core::http` instead of `HttpResponse`. +/// +/// Header write order (last write wins): +/// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent +/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 4. `settings.response_headers` — operator-configured overrides applied last +// Called from FinalizeResponseMiddleware::handle and from tests. +// The struct is gated behind #[allow(dead_code)] until Task 4 wires app.rs. +#[allow(dead_code)] +pub(crate) fn apply_finalize_headers( + settings: &Settings, + geo_info: Option<&GeoInfo>, + response: &mut Response, +) { + if let Some(geo) = geo_info { + geo.set_response_headers(response); + } else { + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); + } + + if let Ok(v) = ::std::env::var(ENV_FASTLY_SERVICE_VERSION) { + if let Ok(value) = HeaderValue::from_str(&v) { + response.headers_mut().insert(HEADER_X_TS_VERSION, value); + } else { + log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); + } + } + + if ::std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { + response + .headers_mut() + .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); + } + + for (key, value) in &settings.response_headers { + let header_name = HeaderName::from_bytes(key.as_bytes()); + let header_value = HeaderValue::from_str(value); + if let (Ok(header_name), Ok(header_value)) = (header_name, header_value) { + response.headers_mut().insert(header_name, header_value); + } else { + log::warn!( + "Skipping invalid configured response header value for {}", + key + ); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + use edgezero_core::body::Body; + use edgezero_core::http::response_builder; + + fn empty_response() -> Response { + response_builder() + .body(Body::empty()) + .expect("should build empty test response") + } + + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { + let mut s = + trusted_server_core::settings_data::get_settings().expect("should load test settings"); + s.response_headers = headers + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + s + } + + #[test] + fn operator_response_headers_override_earlier_headers() { + let settings = + settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); + let mut response = empty_response(); + + // No geo_info → would set "false"; operator header should win instead. + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("operator-override"), + "should override the managed geo header with the operator-configured value" + ); + } + + #[test] + fn sets_geo_unavailable_header_when_no_geo_info() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false when no geo info is available" + ); + } +} From 44108139283de95a39d8b76bb116374e09533d17 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 11:33:27 +0530 Subject: [PATCH 04/22] Simplify EdgeError::internal call and fix comment accuracy in middleware --- crates/trusted-server-adapter-fastly/Cargo.toml | 1 + crates/trusted-server-adapter-fastly/src/middleware.rs | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 2d1191cbc..a2841740b 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +anyhow = "1.0" async-trait = { workspace = true } chrono = { workspace = true } edgezero-adapter-fastly = { workspace = true, features = ["fastly"] } diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index dcd27abb2..a02d7e320 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -114,9 +114,9 @@ impl Middleware for AuthMiddleware { Ok(None) => {} Err(report) => { log::error!("auth check failed: {:?}", report); - return Err(EdgeError::internal(std::io::Error::other(format!( + return Err(EdgeError::internal(anyhow::anyhow!( "auth check failed: {report}" - )))); + ))); } } @@ -139,7 +139,7 @@ impl Middleware for AuthMiddleware { /// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` /// 4. `settings.response_headers` — operator-configured overrides applied last // Called from FinalizeResponseMiddleware::handle and from tests. -// The struct is gated behind #[allow(dead_code)] until Task 4 wires app.rs. +// This function is gated behind #[allow(dead_code)] until Task 4 wires app.rs. #[allow(dead_code)] pub(crate) fn apply_finalize_headers( settings: &Settings, @@ -155,7 +155,7 @@ pub(crate) fn apply_finalize_headers( ); } - if let Ok(v) = ::std::env::var(ENV_FASTLY_SERVICE_VERSION) { + if let Ok(v) = std::env::var(ENV_FASTLY_SERVICE_VERSION) { if let Ok(value) = HeaderValue::from_str(&v) { response.headers_mut().insert(HEADER_X_TS_VERSION, value); } else { @@ -163,7 +163,7 @@ pub(crate) fn apply_finalize_headers( } } - if ::std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { + if std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { response .headers_mut() .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); From 68ddcbab39f8c078cbeef8a88f7acf314bc789a6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 11:34:55 +0530 Subject: [PATCH 05/22] Revert anyhow direct dependency; use std::io::Error::other for EdgeError::internal --- crates/trusted-server-adapter-fastly/Cargo.toml | 1 - crates/trusted-server-adapter-fastly/src/middleware.rs | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index a2841740b..2d1191cbc 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" workspace = true [dependencies] -anyhow = "1.0" async-trait = { workspace = true } chrono = { workspace = true } edgezero-adapter-fastly = { workspace = true, features = ["fastly"] } diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index a02d7e320..05947b57b 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -114,9 +114,12 @@ impl Middleware for AuthMiddleware { Ok(None) => {} Err(report) => { log::error!("auth check failed: {:?}", report); - return Err(EdgeError::internal(anyhow::anyhow!( + // `EdgeError::internal` requires `E: Into`. + // `std::io::Error` satisfies this bound without pulling in anyhow + // as a direct dependency (which the project convention forbids). + return Err(EdgeError::internal(std::io::Error::other(format!( "auth check failed: {report}" - ))); + )))); } } From 8c9cb562f670ffa30bbf2aca32dfcbc6e3722c90 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 15:55:12 +0530 Subject: [PATCH 06/22] Implement TrustedServerApp with all routes via Hooks trait Replace the app.rs stub with the full EdgeZero application wiring: - AppState struct holding Settings, AuctionOrchestrator, IntegrationRegistry, and PlatformKvStore - build_per_request_services() builds RuntimeServices per request using FastlyRequestContext for client IP extraction - http_error() mirrors legacy http_error_response() from main.rs - All 12 routes from legacy route_request() registered on RouterService - Catch-all GET/POST handlers using matchit {*rest} wildcard dispatch to integration proxy or publisher origin fallback - FinalizeResponseMiddleware (outermost) and AuthMiddleware registered --- .../trusted-server-adapter-fastly/src/app.rs | 383 +++++++++++++++++- 1 file changed, 380 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 076dd6c89..2298ae062 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1,11 +1,388 @@ -// Stub implementation — full routing wired in Task 4. -use edgezero_core::app::Hooks; +//! Full `EdgeZero` application wiring for Trusted Server. +//! +//! Registers all routes from the legacy [`crate::route_request`] into a +//! [`RouterService`], attaches [`FinalizeResponseMiddleware`] (outermost) and +//! [`AuthMiddleware`] (inner), and builds the [`AppState`] once at startup. +//! +//! # Route inventory +//! +//! | Method | Path pattern | Handler | +//! |--------|-------------|---------| +//! | GET | `/.well-known/trusted-server.json` | [`handle_trusted_server_discovery`] | +//! | POST | `/verify-signature` | [`handle_verify_signature`] | +//! | POST | `/admin/keys/rotate` | [`handle_rotate_key`] | +//! | POST | `/admin/keys/deactivate` | [`handle_deactivate_key`] | +//! | POST | `/auction` | [`handle_auction`] | +//! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | +//! | GET | `/first-party/click` | [`handle_first_party_click`] | +//! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] | +//! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] | +//! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | +//! | GET | `/static/{*rest}` | [`handle_tsjs_dynamic`] (tsjs prefix) | +//! | GET | `/{*rest}` | integration proxy or publisher fallback | +//! | POST | `/{*rest}` | integration proxy or publisher fallback | + +use std::sync::Arc; + +use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_core::app::{App, Hooks}; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{header, HeaderValue, Response}; use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::platform::{ClientInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::proxy::{ + handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, + handle_first_party_proxy_sign, +}; +use trusted_server_core::publisher::{handle_publisher_request, handle_tsjs_dynamic}; +use trusted_server_core::request_signing::{ + handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, + handle_verify_signature, +}; +use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::get_settings; + +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::platform::open_kv_store; +use crate::platform::{ + FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, + FastlyPlatformSecretStore, UnavailableKvStore, +}; + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +/// Application state built once at startup and shared across all requests. +pub struct AppState { + pub settings: Arc, + pub orchestrator: Arc, + pub registry: Arc, + pub kv_store: Arc, +} + +/// Build the application state, loading settings and constructing all +/// per-application components. +/// +/// On any construction failure the function panics — these are programming +/// errors or unrecoverable misconfiguration that cannot be handled at request +/// time. +fn build_state() -> Arc { + let settings = get_settings().expect("should load trusted-server settings at startup"); + + let orchestrator = + build_orchestrator(&settings).expect("should build auction orchestrator from settings"); + + let registry = IntegrationRegistry::new(&settings) + .expect("should build integration registry from settings"); + + let kv_store = match open_kv_store(&settings.synthetic.opid_store) { + Ok(store) => store, + Err(e) => { + log::warn!( + "KV store '{}' unavailable, synthetic ID routes will return errors: {e}", + settings.synthetic.opid_store + ); + Arc::new(UnavailableKvStore) as Arc + } + }; + + Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + kv_store, + }) +} + +// --------------------------------------------------------------------------- +// Per-request RuntimeServices +// --------------------------------------------------------------------------- + +/// Construct per-request [`RuntimeServices`] from the `EdgeZero` request context. +/// +/// Extracts the client IP address from the [`FastlyRequestContext`] extension +/// inserted by `edgezero_adapter_fastly::dispatch`. TLS metadata is not +/// available through the `EdgeZero` context so those fields are left empty. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + + RuntimeServices::builder() + .config_store(Arc::new(FastlyPlatformConfigStore)) + .secret_store(Arc::new(FastlyPlatformSecretStore)) + .kv_store(Arc::clone(&state.kv_store)) + .backend(Arc::new(FastlyPlatformBackend)) + .http_client(Arc::new(FastlyPlatformHttpClient)) + .geo(Arc::new(FastlyPlatformGeo)) + .client_info(ClientInfo { + client_ip, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- +/// Convert a [`Report`] into an HTTP [`Response`], +/// mirroring [`crate::http_error_response`] exactly. +fn http_error(report: &Report) -> Response { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + + let body = + edgezero_core::body::Body::from(format!("{}\n", root_error.user_message()).into_bytes()); + let mut response = Response::new(body); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +// --------------------------------------------------------------------------- +// TrustedServerApp +// --------------------------------------------------------------------------- + +/// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. pub struct TrustedServerApp; impl Hooks for TrustedServerApp { + fn name() -> &'static str { + "TrustedServer" + } + fn routes() -> RouterService { - RouterService::builder().build() + let state = Arc::new(build_state()); + + // /.well-known/trusted-server.json + let s = Arc::clone(&state); + let discovery_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_trusted_server_discovery(&s.settings, &services, req) + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // /verify-signature + let s = Arc::clone(&state); + let verify_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_verify_signature(&s.settings, &services, req) + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // /admin/keys/rotate + let s = Arc::clone(&state); + let rotate_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_rotate_key(&s.settings, &services, req) + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // /admin/keys/deactivate + let s = Arc::clone(&state); + let deactivate_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_deactivate_key(&s.settings, &services, req) + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // /auction + let s = Arc::clone(&state); + let auction_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_auction(&s.settings, &s.orchestrator, &services, req) + .await + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // /first-party/proxy + let s = Arc::clone(&state); + let fp_proxy_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_first_party_proxy(&s.settings, &services, req) + .await + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // /first-party/click + let s = Arc::clone(&state); + let fp_click_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_first_party_click(&s.settings, &services, req) + .await + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // GET /first-party/sign + let s = Arc::clone(&state); + let fp_sign_get_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_first_party_proxy_sign(&s.settings, &services, req) + .await + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // POST /first-party/sign + let s = Arc::clone(&state); + let fp_sign_post_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_first_party_proxy_sign(&s.settings, &services, req) + .await + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // /first-party/proxy-rebuild + let s = Arc::clone(&state); + let fp_rebuild_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + handle_first_party_proxy_rebuild(&s.settings, &services, req) + .await + .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + } + }; + + // GET /static/{*rest} — tsjs dynamic bundles + let s = Arc::clone(&state); + let tsjs_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let response = handle_tsjs_dynamic(ctx.request(), &s.registry) + .unwrap_or_else(|e| http_error(&e)); + Ok::(response) + } + }; + + // GET /{*rest} — integration proxy or publisher origin fallback + let s = Arc::clone(&state); + let get_fallback = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + let result = if s.registry.has_route(&method, &path) { + s.registry + .handle_proxy(&method, &path, &s.settings, &services, req) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }) + } else { + log::info!( + "No known route matched for path: {}, proxying to publisher origin", + path + ); + handle_publisher_request(&s.settings, &s.registry, &services, req).await + }; + + Ok::(result.unwrap_or_else(|e| http_error(&e))) + } + }; + + // POST /{*rest} — integration proxy or publisher origin fallback + let s = Arc::clone(&state); + let post_fallback = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_per_request_services(&s, &ctx); + let req = ctx.into_request(); + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + let result = if s.registry.has_route(&method, &path) { + s.registry + .handle_proxy(&method, &path, &s.settings, &services, req) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }) + } else { + log::info!( + "No known route matched for path: {}, proxying to publisher origin", + path + ); + handle_publisher_request(&s.settings, &s.registry, &services, req).await + }; + + Ok::(result.unwrap_or_else(|e| http_error(&e))) + } + }; + + RouterService::builder() + .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) + .get("/.well-known/trusted-server.json", discovery_handler) + .post("/verify-signature", verify_handler) + .post("/admin/keys/rotate", rotate_handler) + .post("/admin/keys/deactivate", deactivate_handler) + .post("/auction", auction_handler) + .get("/first-party/proxy", fp_proxy_handler) + .get("/first-party/click", fp_click_handler) + .get("/first-party/sign", fp_sign_get_handler) + .post("/first-party/sign", fp_sign_post_handler) + .post("/first-party/proxy-rebuild", fp_rebuild_handler) + .get("/static/{*rest}", tsjs_handler) + .get("/{*rest}", get_fallback) + .post("/{*rest}", post_fallback) + .build() + } + + fn configure(app: &mut App) { + app.set_name("TrustedServer"); } } From 5e309066728d7811d1adf405c338e249770c3615 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 16:00:11 +0530 Subject: [PATCH 07/22] Fix app.rs spec gaps: remove double-Arc, move tsjs into catch-all, fix handler pattern - Remove Arc::new() wrapper around build_state() which already returns Arc - Remove dedicated GET /static/{*rest} route and its tsjs_handler closure - Move tsjs handling into GET /{*rest} catch-all: check path.starts_with("/static/tsjs=") first - Extract path/method from ctx.request() before ctx.into_request() to keep &req valid - Replace .map_err(|e| EdgeError::internal(...)) with .unwrap_or_else(|e| http_error(&e)) in all named-route handlers - Remove configure() method from TrustedServerApp (not part of spec) - Remove unused App import --- .../trusted-server-adapter-fastly/src/app.rs | 91 +++++++------------ 1 file changed, 35 insertions(+), 56 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 2298ae062..bd551c546 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -18,14 +18,13 @@ //! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] | //! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] | //! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | -//! | GET | `/static/{*rest}` | [`handle_tsjs_dynamic`] (tsjs prefix) | -//! | GET | `/{*rest}` | integration proxy or publisher fallback | +//! | GET | `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | //! | POST | `/{*rest}` | integration proxy or publisher fallback | use std::sync::Arc; use edgezero_adapter_fastly::FastlyRequestContext; -use edgezero_core::app::{App, Hooks}; +use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{header, HeaderValue, Response}; @@ -162,7 +161,7 @@ impl Hooks for TrustedServerApp { } fn routes() -> RouterService { - let state = Arc::new(build_state()); + let state = build_state(); // /.well-known/trusted-server.json let s = Arc::clone(&state); @@ -171,8 +170,8 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_trusted_server_discovery(&s.settings, &services, req) - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + Ok(handle_trusted_server_discovery(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -183,8 +182,8 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_verify_signature(&s.settings, &services, req) - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + Ok(handle_verify_signature(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -195,8 +194,8 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_rotate_key(&s.settings, &services, req) - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + Ok(handle_rotate_key(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -207,8 +206,8 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_deactivate_key(&s.settings, &services, req) - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + Ok(handle_deactivate_key(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -219,9 +218,9 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_auction(&s.settings, &s.orchestrator, &services, req) + Ok(handle_auction(&s.settings, &s.orchestrator, &services, req) .await - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -232,9 +231,9 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_first_party_proxy(&s.settings, &services, req) + Ok(handle_first_party_proxy(&s.settings, &services, req) .await - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -245,9 +244,9 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_first_party_click(&s.settings, &services, req) + Ok(handle_first_party_click(&s.settings, &services, req) .await - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -258,9 +257,9 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_first_party_proxy_sign(&s.settings, &services, req) + Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -271,9 +270,9 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_first_party_proxy_sign(&s.settings, &services, req) + Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) + .unwrap_or_else(|e| http_error(&e))) } }; @@ -284,34 +283,27 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - handle_first_party_proxy_rebuild(&s.settings, &services, req) - .await - .map_err(|e| EdgeError::internal(std::io::Error::other(format!("{e}")))) - } - }; - - // GET /static/{*rest} — tsjs dynamic bundles - let s = Arc::clone(&state); - let tsjs_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let response = handle_tsjs_dynamic(ctx.request(), &s.registry) - .unwrap_or_else(|e| http_error(&e)); - Ok::(response) + Ok( + handle_first_party_proxy_rebuild(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) } }; - // GET /{*rest} — integration proxy or publisher origin fallback + // GET /{*rest} — tsjs (if /static/tsjs= prefix), integration proxy, or publisher fallback let s = Arc::clone(&state); let get_fallback = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { + Box::pin(async move { let services = build_per_request_services(&s, &ctx); + let path = ctx.request().uri().path().to_string(); + let method = ctx.request().method().clone(); let req = ctx.into_request(); - let path = req.uri().path().to_string(); - let method = req.method().clone(); - let result = if s.registry.has_route(&method, &path) { + let result = if path.starts_with("/static/tsjs=") { + handle_tsjs_dynamic(&req, &s.registry) + } else if s.registry.has_route(&method, &path) { s.registry .handle_proxy(&method, &path, &s.settings, &services, req) .await @@ -321,15 +313,11 @@ impl Hooks for TrustedServerApp { })) }) } else { - log::info!( - "No known route matched for path: {}, proxying to publisher origin", - path - ); handle_publisher_request(&s.settings, &s.registry, &services, req).await }; - Ok::(result.unwrap_or_else(|e| http_error(&e))) - } + Ok(result.unwrap_or_else(|e| http_error(&e))) + }) }; // POST /{*rest} — integration proxy or publisher origin fallback @@ -352,10 +340,6 @@ impl Hooks for TrustedServerApp { })) }) } else { - log::info!( - "No known route matched for path: {}, proxying to publisher origin", - path - ); handle_publisher_request(&s.settings, &s.registry, &services, req).await }; @@ -376,13 +360,8 @@ impl Hooks for TrustedServerApp { .get("/first-party/sign", fp_sign_get_handler) .post("/first-party/sign", fp_sign_post_handler) .post("/first-party/proxy-rebuild", fp_rebuild_handler) - .get("/static/{*rest}", tsjs_handler) .get("/{*rest}", get_fallback) .post("/{*rest}", post_fallback) .build() } - - fn configure(app: &mut App) { - app.set_name("TrustedServer"); - } } From e1bce63e46cf51e412bd52d41652c35f066f2563 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 16:19:13 +0530 Subject: [PATCH 08/22] Clean up app.rs: remove gratuitous allocation, Box::pin inconsistency, unused turbofish, and overly-broad field visibility - Drop `.into_bytes()` in `http_error`; `Body` implements `From` directly - Remove `Box::pin` wrapper from `get_fallback` closure; plain `async move` matches all other handlers - Remove `Ok::` turbofish in `post_fallback`; type is now inferred - Drop now-unused `EdgeError` import that was only needed for the turbofish - Narrow `AppState` field visibility from `pub` to `pub(crate)`; struct is internal to this crate --- .../trusted-server-adapter-fastly/src/app.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index bd551c546..e71ca96ba 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -26,7 +26,6 @@ use std::sync::Arc; use edgezero_adapter_fastly::FastlyRequestContext; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; -use edgezero_core::error::EdgeError; use edgezero_core::http::{header, HeaderValue, Response}; use edgezero_core::router::RouterService; use error_stack::Report; @@ -60,10 +59,10 @@ use crate::platform::{ /// Application state built once at startup and shared across all requests. pub struct AppState { - pub settings: Arc, - pub orchestrator: Arc, - pub registry: Arc, - pub kv_store: Arc, + pub(crate) settings: Arc, + pub(crate) orchestrator: Arc, + pub(crate) registry: Arc, + pub(crate) kv_store: Arc, } /// Build the application state, loading settings and constructing all @@ -137,8 +136,7 @@ fn http_error(report: &Report) -> Response { let root_error = report.current_context(); log::error!("Error occurred: {:?}", report); - let body = - edgezero_core::body::Body::from(format!("{}\n", root_error.user_message()).into_bytes()); + let body = edgezero_core::body::Body::from(format!("{}\n", root_error.user_message())); let mut response = Response::new(body); *response.status_mut() = root_error.status_code(); response.headers_mut().insert( @@ -295,7 +293,7 @@ impl Hooks for TrustedServerApp { let s = Arc::clone(&state); let get_fallback = move |ctx: RequestContext| { let s = Arc::clone(&s); - Box::pin(async move { + async move { let services = build_per_request_services(&s, &ctx); let path = ctx.request().uri().path().to_string(); let method = ctx.request().method().clone(); @@ -317,7 +315,7 @@ impl Hooks for TrustedServerApp { }; Ok(result.unwrap_or_else(|e| http_error(&e))) - }) + } }; // POST /{*rest} — integration proxy or publisher origin fallback @@ -343,7 +341,7 @@ impl Hooks for TrustedServerApp { handle_publisher_request(&s.settings, &s.registry, &services, req).await }; - Ok::(result.unwrap_or_else(|e| http_error(&e))) + Ok(result.unwrap_or_else(|e| http_error(&e))) } }; From 181d866992324ca4503b64cb2f3fe02540cb3f66 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 16:32:03 +0530 Subject: [PATCH 09/22] Reference legacy-cleanup issue in legacy_main comment --- crates/trusted-server-adapter-fastly/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 5ba9e497f..0830ee5c4 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -105,6 +105,7 @@ fn main(req: FastlyRequest) -> Result { /// # Errors /// /// Propagates [`fastly::Error`] from the Fastly SDK. +// TODO: delete after Phase 5 EdgeZero cutover — see issue #495 fn legacy_main(mut req: FastlyRequest) -> Result { let settings = match get_settings() { Ok(s) => s, From e8e0d9fb2c72e211b644af7daed293f54640f176 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 16:45:50 +0530 Subject: [PATCH 10/22] Fix lint issues --- .claude/settings.json | 2 +- crates/trusted-server-core/src/auction/orchestrator.rs | 1 - .../src/integrations/google_tag_manager.rs | 9 ++++----- crates/trusted-server-core/src/integrations/lockr.rs | 3 +-- crates/trusted-server-core/src/integrations/permutive.rs | 3 +-- crates/trusted-server-core/src/integrations/prebid.rs | 4 ++-- crates/trusted-server-core/src/integrations/testlight.rs | 4 ++-- 7 files changed, 11 insertions(+), 15 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 02b602d4f..b7b0afdc5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,7 +25,7 @@ "Bash(git status:*)", "mcp__plugin_chrome-devtools-mcp_chrome-devtools__new_page", "mcp__plugin_chrome-devtools-mcp_chrome-devtools__performance_stop_trace", - "mcp__plugin_chrome-devtools-mcp_chrome-devtools__evaluate_script", + "mcp__plugin_chrome-devtools-mcp_chrome-devtools__evaluate_script" ] }, "enabledPlugins": { diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index b66ef9b89..9f0128b18 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -12,7 +12,6 @@ use super::config::AuctionConfig; use super::provider::AuctionProvider; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; - /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index fc8164742..5d7a17268 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -16,8 +16,8 @@ use std::sync::{Arc, LazyLock}; use async_trait::async_trait; use edgezero_core::body::Body as EdgeBody; -use futures::StreamExt as _; use error_stack::{Report, ResultExt}; +use futures::StreamExt as _; use http::{header, Method, Request, Response, StatusCode}; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -25,9 +25,9 @@ use validator::Validate; use crate::error::TrustedServerError; use crate::integrations::{ - collect_body, AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, IntegrationScriptContext, - IntegrationScriptRewriter, ScriptRewriteAction, + collect_body, AttributeRewriteAction, IntegrationAttributeContext, + IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, + IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, }; use crate::platform::RuntimeServices; use crate::proxy::{proxy_request, ProxyRequestConfig}; @@ -333,7 +333,6 @@ impl GoogleTagManagerIntegration { } } } - } fn build( diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 9e868bba8..996c9535c 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -270,8 +270,7 @@ impl LockrIntegration { for (name, value) in from { let name_str = name.as_str(); - if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) - { + if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) { to.append(name.clone(), value.clone()); } } diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index d6f7e255c..c384b43cc 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -264,8 +264,7 @@ impl PermutiveIntegration { // Copy any X-* custom headers, skipping TS-internal headers for (name, value) in from { let name_str = name.as_str(); - if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) - { + if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) { to.append(name.clone(), value.clone()); } } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d3131d119..e6aca27b8 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -5,11 +5,11 @@ use std::time::Duration; use async_trait::async_trait; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use http::{header, Method, StatusCode}; use http::header::HeaderValue; -use url::Url; +use http::{header, Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; +use url::Url; use validator::Validate; use crate::auction::provider::AuctionProvider; diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index aa14ac72a..9196165d5 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -11,8 +11,8 @@ use validator::Validate; use crate::error::TrustedServerError; use crate::integrations::{ - collect_body, AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, + collect_body, AttributeRewriteAction, IntegrationAttributeContext, + IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, }; use crate::platform::RuntimeServices; use crate::proxy::{proxy_request, ProxyRequestConfig}; From d3c2798a5395a7951e2c0750e7fc776b39d7d3bd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 19:46:52 +0530 Subject: [PATCH 11/22] Pin edgezero dependencies to branch=main and bump toml to 1.1 Switches all four edgezero workspace dependencies from rev=170b74b to branch=main so the adapter can use dispatch_with_config, the non-deprecated public dispatch path. The main branch requires toml ^1.1, so the workspace pin is bumped from "1.0" to "1.1" to resolve the version conflict. --- Cargo.lock | 46 ++++++++++++++++++++++++++-------------------- Cargo.toml | 10 +++++----- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e1620679..6ccd29d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?branch=main#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-stream", @@ -800,7 +800,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?branch=main#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-compression", @@ -818,7 +818,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "thiserror 2.0.17", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tower-service", "tracing", "validator", @@ -828,14 +828,14 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?branch=main#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "log", "proc-macro2", "quote", "serde", "syn 2.0.111", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "validator", ] @@ -1246,6 +1246,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.10.0" @@ -1468,12 +1474,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", ] [[package]] @@ -2352,9 +2358,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -2691,14 +2697,14 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.7+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.0.1+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.0", @@ -2715,27 +2721,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.7+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower-service" @@ -2835,7 +2841,7 @@ dependencies = [ "temp-env", "tokio", "tokio-test", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "trusted-server-js", "trusted-server-openrtb", "url", diff --git a/Cargo.toml b/Cargo.toml index 4a62fd6c7..03f9dc888 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,10 +56,10 @@ config = "0.15.19" cookie = "0.18.1" derive_more = { version = "2.0", features = ["display", "error"] } ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } +edgezero-core = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } error-stack = "0.6" fastly = "0.11.12" fern = "0.7.1" @@ -86,7 +86,7 @@ subtle = "2.6" temp-env = "0.3.6" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } tokio-test = "0.4" -toml = "1.0" +toml = "1.1" url = "2.5.8" urlencoding = "2.1" uuid = { version = "1.18", features = ["v4"] } From a3001097ec6ec5a8a0737ebed5721ff4da7f9d51 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 19:47:39 +0530 Subject: [PATCH 12/22] Switch EdgeZero dispatch to dispatch_with_config, add routing log lines Replaces the deprecated dispatch() call with dispatch_with_config(), which injects the named config store into request extensions without initialising the logger a second time (a second set_logger call would panic because the custom fern logger is already initialised above). Adds log::info lines for both the EdgeZero and legacy routing paths. --- crates/trusted-server-adapter-fastly/src/main.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 0830ee5c4..8ed20511f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -86,9 +86,15 @@ fn main(req: FastlyRequest) -> Result { log::warn!("failed to read edgezero_enabled flag, falling back to legacy path: {e}"); false }) { + log::info!("routing request through EdgeZero path"); let app = TrustedServerApp::build_app(); - edgezero_adapter_fastly::dispatch(&app, req) + // `run_app_with_config` and `run_app_with_logging` call `init_logger` + // internally — a second `set_logger` call panics because our custom + // fern logger is already initialised above. `dispatch_with_config` + // skips logger initialisation and injects the config store directly. + edgezero_adapter_fastly::dispatch_with_config(&app, req, "trusted_server_config") } else { + log::info!("routing request through legacy path"); legacy_main(req) } } From 86a44b62a5dbc5385adae4a4935fa164d1a06501 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 19:47:48 +0530 Subject: [PATCH 13/22] Register explicit GET / and POST / routes to cover matchit root path gap matchit's /{*rest} catch-all does not match the bare root path /. Add explicit .get("/", ...) and .post("/", ...) routes that clone the fallback closures so requests to / reach the publisher origin fallback rather than returning a 404. --- crates/trusted-server-adapter-fastly/src/app.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index e71ca96ba..a4d2bbaa7 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -358,6 +358,10 @@ impl Hooks for TrustedServerApp { .get("/first-party/sign", fp_sign_get_handler) .post("/first-party/sign", fp_sign_post_handler) .post("/first-party/proxy-rebuild", fp_rebuild_handler) + // matchit's `/{*rest}` does not match the bare root `/` — register + // explicit root routes so `/` reaches the publisher fallback too. + .get("/", get_fallback.clone()) + .post("/", post_fallback.clone()) .get("/{*rest}", get_fallback) .post("/{*rest}", post_fallback) .build() From a48d956dbb84b1115871b831845d1fb40691f67d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 19:47:55 +0530 Subject: [PATCH 14/22] Add trusted_server_config config store for local dev Registers the trusted_server_config config store in fastly.toml with edgezero_enabled = "true" so that fastly compute serve routes requests through the EdgeZero path without needing a deployed service. --- fastly.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fastly.toml b/fastly.toml index 30665b31b..425eacbb3 100644 --- a/fastly.toml +++ b/fastly.toml @@ -48,6 +48,11 @@ build = """ env = "FASTLY_KEY" [local_server.config_stores] + [local_server.config_stores.trusted_server_config] + format = "inline-toml" + [local_server.config_stores.trusted_server_config.contents] + edgezero_enabled = "true" + [local_server.config_stores.jwks_store] format = "inline-toml" [local_server.config_stores.jwks_store.contents] From dacc403f1df985660756996998609a710d220a0a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Apr 2026 21:58:12 +0530 Subject: [PATCH 15/22] Address PR review findings in app.rs - Normalise get_fallback to extract path/method from req after consuming the context, consistent with post_fallback and avoiding a double borrow on ctx - Add comment to http_error documenting the intentional duplication with http_error_response in main.rs (different HTTP type systems; removable in PR 15) - Add comment above route handlers explaining why the explicit per-handler pattern is kept over a macro abstraction --- crates/trusted-server-adapter-fastly/src/app.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index a4d2bbaa7..059896478 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -132,6 +132,10 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime /// Convert a [`Report`] into an HTTP [`Response`], /// mirroring [`crate::http_error_response`] exactly. +/// +/// The near-identical function in `main.rs` is intentional: the legacy path +/// uses fastly HTTP types while this path uses `edgezero_core` types. The +/// duplication will be removed when `legacy_main` is deleted in PR 15. fn http_error(report: &Report) -> Response { let root_error = report.current_context(); log::error!("Error occurred: {:?}", report); @@ -161,6 +165,13 @@ impl Hooks for TrustedServerApp { fn routes() -> RouterService { let state = build_state(); + // Each handler below follows the same pattern: clone state, build + // per-request services, consume the context into the request, call the + // core handler, and convert errors with http_error. The pattern is kept + // explicit rather than abstracted into a macro so each route can be + // audited in isolation and handlers with differing signatures (sync vs + // async, extra orchestrator argument) remain readable without special-casing. + // /.well-known/trusted-server.json let s = Arc::clone(&state); let discovery_handler = move |ctx: RequestContext| { @@ -295,9 +306,9 @@ impl Hooks for TrustedServerApp { let s = Arc::clone(&s); async move { let services = build_per_request_services(&s, &ctx); - let path = ctx.request().uri().path().to_string(); - let method = ctx.request().method().clone(); let req = ctx.into_request(); + let path = req.uri().path().to_string(); + let method = req.method().clone(); let result = if path.starts_with("/static/tsjs=") { handle_tsjs_dynamic(&req, &s.registry) From 40b4598a0fa3869d5cbb5982eaf981863ab9be90 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 16 Apr 2026 17:48:02 +0530 Subject: [PATCH 16/22] Resolve PR review findings and format lint --- Cargo.lock | 6 +- Cargo.toml | 8 +- .../trusted-server-adapter-fastly/src/app.rs | 86 ++++++++++++++----- .../trusted-server-adapter-fastly/src/main.rs | 16 ++-- .../src/middleware.rs | 35 +++----- .../src/auction/endpoints.rs | 11 ++- .../src/auction/orchestrator.rs | 5 +- .../src/integrations/google_tag_manager.rs | 5 +- .../src/integrations/permutive.rs | 15 ++-- .../src/integrations/prebid.rs | 8 +- 10 files changed, 116 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ccd29d81..186cfecd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#38198f9839b70aef03ab971ae5876982773fc2a1" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-stream", @@ -800,7 +800,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#38198f9839b70aef03ab971ae5876982773fc2a1" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-compression", @@ -828,7 +828,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#38198f9839b70aef03ab971ae5876982773fc2a1" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "log", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 03f9dc888..13b15135c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,10 +56,10 @@ config = "0.15.19" cookie = "0.18.1" derive_more = { version = "2.0", features = ["display", "error"] } ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } -edgezero-core = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } error-stack = "0.6" fastly = "0.11.12" fern = "0.7.1" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 059896478..9150583ff 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -26,6 +26,7 @@ use std::sync::Arc; use edgezero_adapter_fastly::FastlyRequestContext; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; use edgezero_core::http::{header, HeaderValue, Response}; use edgezero_core::router::RouterService; use error_stack::Report; @@ -57,28 +58,29 @@ use crate::platform::{ // AppState // --------------------------------------------------------------------------- -/// Application state built once at startup and shared across all requests. +/// Application state built once per Wasm instance and shared for its lifetime. +/// +/// In Fastly Compute each request spawns a new Wasm instance, so this struct is +/// effectively per-request. It holds pre-parsed settings and all service handles. pub struct AppState { - pub(crate) settings: Arc, - pub(crate) orchestrator: Arc, - pub(crate) registry: Arc, - pub(crate) kv_store: Arc, + settings: Arc, + orchestrator: Arc, + registry: Arc, + kv_store: Arc, } -/// Build the application state, loading settings and constructing all -/// per-application components. +/// Build the application state, loading settings and constructing all per-application components. +/// +/// # Errors /// -/// On any construction failure the function panics — these are programming -/// errors or unrecoverable misconfiguration that cannot be handled at request -/// time. -fn build_state() -> Arc { - let settings = get_settings().expect("should load trusted-server settings at startup"); +/// Returns an error when settings, the auction orchestrator, or the integration +/// registry fail to initialise. +fn build_state() -> Result, Report> { + let settings = get_settings()?; - let orchestrator = - build_orchestrator(&settings).expect("should build auction orchestrator from settings"); + let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings) - .expect("should build integration registry from settings"); + let registry = IntegrationRegistry::new(&settings)?; let kv_store = match open_kv_store(&settings.synthetic.opid_store) { Ok(store) => store, @@ -91,12 +93,12 @@ fn build_state() -> Arc { } }; - Arc::new(AppState { + Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), kv_store, - }) + })) } // --------------------------------------------------------------------------- @@ -136,7 +138,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime /// The near-identical function in `main.rs` is intentional: the legacy path /// uses fastly HTTP types while this path uses `edgezero_core` types. The /// duplication will be removed when `legacy_main` is deleted in PR 15. -fn http_error(report: &Report) -> Response { +pub(crate) fn http_error(report: &Report) -> Response { let root_error = report.current_context(); log::error!("Error occurred: {:?}", report); @@ -150,6 +152,39 @@ fn http_error(report: &Report) -> Response { response } +// --------------------------------------------------------------------------- +// Startup error fallback +// --------------------------------------------------------------------------- + +/// Returns a [`RouterService`] that responds to every route with the startup error. +/// +/// Called when [`build_state`] fails so that request handling degrades to a +/// structured HTTP error response rather than an unrecoverable panic. +fn startup_error_router(e: &Report) -> RouterService { + let message = Arc::new(format!("{}\n", e.current_context().user_message())); + let status = e.current_context().status_code(); + + let make = move |msg: Arc| { + move |_ctx: RequestContext| { + let body = edgezero_core::body::Body::from((*msg).clone()); + let mut resp = Response::new(body); + *resp.status_mut() = status; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + async move { Ok::(resp) } + } + }; + + RouterService::builder() + .get("/", make(Arc::clone(&message))) + .post("/", make(Arc::clone(&message))) + .get("/{*rest}", make(Arc::clone(&message))) + .post("/{*rest}", make(Arc::clone(&message))) + .build() +} + // --------------------------------------------------------------------------- // TrustedServerApp // --------------------------------------------------------------------------- @@ -163,7 +198,13 @@ impl Hooks for TrustedServerApp { } fn routes() -> RouterService { - let state = build_state(); + let state = match build_state() { + Ok(s) => s, + Err(ref e) => { + log::error!("failed to build application state: {:?}", e); + return startup_error_router(e); + } + }; // Each handler below follows the same pattern: clone state, build // per-request services, consume the context into the request, call the @@ -357,7 +398,10 @@ impl Hooks for TrustedServerApp { }; RouterService::builder() - .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(FinalizeResponseMiddleware::new( + Arc::clone(&state.settings), + Arc::new(FastlyPlatformGeo), + )) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get("/.well-known/trusted-server.json", discovery_handler) .post("/verify-signature", verify_handler) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index e74eb37b3..0819ee0e9 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -48,7 +48,7 @@ use edgezero_core::app::Hooks as _; /// All other values, including the empty string, are treated as disabled. fn parse_edgezero_flag(value: &str) -> bool { let v = value.trim(); - v == "true" || v == "1" + v.eq_ignore_ascii_case("true") || v == "1" } /// Reads the `edgezero_enabled` key from the `"trusted_server_config"` Fastly @@ -71,7 +71,7 @@ fn is_edgezero_enabled() -> Result { } #[fastly::main] -fn main(req: FastlyRequest) -> Result { +fn main(mut req: FastlyRequest) -> Result { // Health probe bypasses routing, settings, and app construction — cheap liveness signal. if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { return Ok(FastlyResponse::from_status(200).with_body_text_plain("ok")); @@ -88,6 +88,9 @@ fn main(req: FastlyRequest) -> Result { }) { log::info!("routing request through EdgeZero path"); let app = TrustedServerApp::build_app(); + // Strip client-spoofable forwarded headers before handing off to the + // EdgeZero dispatcher, mirroring the sanitization done in legacy_main. + compat::sanitize_fastly_forwarded_headers(&mut req); // `run_app_with_config` and `run_app_with_logging` call `init_logger` // internally — a second `set_logger` call panics because our custom // fern logger is already initialised above. `dispatch_with_config` @@ -337,6 +340,11 @@ mod tests { parse_edgezero_flag(" 1 "), "should trim whitespace around '1'" ); + assert!(parse_edgezero_flag("TRUE"), "should parse uppercase 'TRUE'"); + assert!( + parse_edgezero_flag("True"), + "should parse mixed-case 'True'" + ); } #[test] @@ -348,9 +356,5 @@ mod tests { "should not parse whitespace-only" ); assert!(!parse_edgezero_flag("yes"), "should not parse 'yes'"); - assert!( - !parse_edgezero_flag("TRUE"), - "should not parse uppercase 'TRUE'" - ); } } diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 05947b57b..e26caaf1f 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -24,11 +24,9 @@ use trusted_server_core::constants::{ HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; use trusted_server_core::geo::GeoInfo; -use trusted_server_core::platform::PlatformGeo as _; +use trusted_server_core::platform::PlatformGeo; use trusted_server_core::settings::Settings; -use crate::platform::FastlyPlatformGeo; - // --------------------------------------------------------------------------- // FinalizeResponseMiddleware // --------------------------------------------------------------------------- @@ -46,17 +44,15 @@ use crate::platform::FastlyPlatformGeo; /// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var /// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` /// 4. Operator-configured `settings.response_headers` (can override any managed header) -// Used in Task 4 when app.rs registers the middleware chain. -#[allow(dead_code)] pub struct FinalizeResponseMiddleware { settings: Arc, + geo: Arc, } impl FinalizeResponseMiddleware { - /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. - #[allow(dead_code)] - pub fn new(settings: Arc) -> Self { - Self { settings } + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings and geo lookup service. + pub fn new(settings: Arc, geo: Arc) -> Self { + Self { settings, geo } } } @@ -65,7 +61,7 @@ impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); - let geo_info = FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + let geo_info = self.geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("geo lookup failed: {e}"); None }); @@ -87,20 +83,19 @@ impl Middleware for FinalizeResponseMiddleware { /// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the /// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). /// - `Ok(None)` → no auth required or credentials accepted; continue the chain. -/// - `Err(report)` → internal error; log and return [`EdgeError::internal`]. +/// - `Err(report)` → internal error; log and convert to a 500 HTTP response. /// /// # Errors /// -/// Returns [`EdgeError::internal`] when [`enforce_basic_auth`] returns an error report. -// Used in Task 4 when app.rs registers the middleware chain. -#[allow(dead_code)] +/// When [`enforce_basic_auth`] returns an error report, converts it to a 500 HTTP +/// response so that [`FinalizeResponseMiddleware`] can still inject standard TS +/// headers before the response reaches the client. pub struct AuthMiddleware { settings: Arc, } impl AuthMiddleware { /// Creates a new [`AuthMiddleware`] with the given settings. - #[allow(dead_code)] pub fn new(settings: Arc) -> Self { Self { settings } } @@ -114,12 +109,7 @@ impl Middleware for AuthMiddleware { Ok(None) => {} Err(report) => { log::error!("auth check failed: {:?}", report); - // `EdgeError::internal` requires `E: Into`. - // `std::io::Error` satisfies this bound without pulling in anyhow - // as a direct dependency (which the project convention forbids). - return Err(EdgeError::internal(std::io::Error::other(format!( - "auth check failed: {report}" - )))); + return Ok(crate::app::http_error(&report)); } } @@ -141,9 +131,6 @@ impl Middleware for AuthMiddleware { /// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var /// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` /// 4. `settings.response_headers` — operator-configured overrides applied last -// Called from FinalizeResponseMiddleware::handle and from tests. -// This function is gated behind #[allow(dead_code)] until Task 4 wires app.rs. -#[allow(dead_code)] pub(crate) fn apply_finalize_headers( settings: &Settings, geo_info: Option<&GeoInfo>, diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 77863939c..28723da9b 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -39,12 +39,11 @@ pub async fn handle_auction( let (parts, body) = req.into_parts(); // Parse request body — use a bounded read so streaming bodies cannot exhaust memory. - let body_bytes = - collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, "auction") - .await - .change_context(TrustedServerError::Auction { - message: "Failed to read auction request body".to_string(), - })?; + let body_bytes = collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, "auction") + .await + .change_context(TrustedServerError::Auction { + message: "Failed to read auction request body".to_string(), + })?; let body: AdRequest = serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { message: "Failed to parse auction request body".to_string(), diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 720d1b55e..4cd50f0cc 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -401,7 +401,10 @@ impl AuctionOrchestrator { { let response_time_ms = start_time.elapsed().as_millis() as u64; - match provider.parse_response(platform_response, response_time_ms).await { + match provider + .parse_response(platform_response, response_time_ms) + .await + { Ok(auction_response) => { log::info!( "Provider '{}' returned {} bids (status: {:?}, time: {}ms)", diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index b8a5eab32..41fd44e1a 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -39,7 +39,10 @@ const DEFAULT_UPSTREAM: &str = "https://www.googletagmanager.com"; /// Error type for payload size validation #[derive(Debug)] enum PayloadSizeError { - TooLarge { actual: usize, max: usize }, + TooLarge { + actual: usize, + max: usize, + }, /// Transport error while reading a streaming body chunk. StreamRead(String), } diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index 177c59f5a..fe6ed281c 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -208,15 +208,12 @@ impl PermutiveIntegration { log::info!("Forwarding {} to {}", route_name, target_url); let request_body = if matches!(method, Method::POST | Method::PUT | Method::PATCH) { - let bytes = collect_body_bounded( - body, - INTEGRATION_MAX_BODY_BYTES, - PERMUTIVE_INTEGRATION_ID, - ) - .await - .change_context(Self::error(format!( - "Permutive {route_name} request body too large" - )))?; + let bytes = + collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, PERMUTIVE_INTEGRATION_ID) + .await + .change_context(Self::error(format!( + "Permutive {route_name} request body too large" + )))?; EdgeBody::from(bytes) } else { EdgeBody::empty() diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c94b42b07..46894d5d1 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1154,11 +1154,11 @@ impl AuctionProvider for PrebidAuctionProvider { let status = response.status(); // Parse response — collect_body handles both Once and Stream variants safely. - let body_bytes = collect_body(response.into_body(), "prebid").await.change_context( - TrustedServerError::Prebid { + let body_bytes = collect_body(response.into_body(), "prebid") + .await + .change_context(TrustedServerError::Prebid { message: "Failed to read Prebid response body".to_string(), - }, - )?; + })?; if !status.is_success() { log::warn!("Prebid returned non-success status: {}", status,); From 373c8e459c62f545457c529d3b20f4927b0d8bfa Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 21 Apr 2026 09:26:47 +0530 Subject: [PATCH 17/22] Address PR14 review findings: middleware finalize and missing HTTP methods FinalizeResponseMiddleware now absorbs errors from inner middleware/handlers by converting EdgeError to a Response via IntoResponse, so apply_finalize_headers always runs regardless of handler outcome. Geo lookup is moved after next.run and skipped for UNAUTHORIZED responses to avoid unnecessary backend calls. Register HEAD and OPTIONS catch-all routes so cache-validation and CORS preflight requests reach the publisher origin instead of returning 405. Update module docstring to note startup_error_router skips middleware. --- .../trusted-server-adapter-fastly/src/app.rs | 15 +++++++++++- .../src/middleware.rs | 24 ++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 9150583ff..d626d7504 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -20,6 +20,14 @@ //! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | //! | GET | `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | //! | POST | `/{*rest}` | integration proxy or publisher fallback | +//! | HEAD | `/{*rest}` | publisher fallback (cache validation) | +//! | OPTIONS | `/{*rest}` | publisher fallback (CORS preflight) | +//! +//! # Startup error handling +//! +//! When [`build_state`] fails, [`startup_error_router`] returns a minimal router +//! that responds to all routes with the startup error. This router does **not** +//! attach middleware — startup errors are returned without geo or TS headers. use std::sync::Arc; @@ -27,7 +35,7 @@ use edgezero_adapter_fastly::FastlyRequestContext; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{header, HeaderValue, Response}; +use edgezero_core::http::{header, HeaderValue, Method, Response}; use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; @@ -417,6 +425,11 @@ impl Hooks for TrustedServerApp { // explicit root routes so `/` reaches the publisher fallback too. .get("/", get_fallback.clone()) .post("/", post_fallback.clone()) + // HEAD and OPTIONS reach the publisher origin for cache validation and CORS preflight. + .route("/", Method::HEAD, get_fallback.clone()) + .route("/{*rest}", Method::HEAD, get_fallback.clone()) + .route("/", Method::OPTIONS, get_fallback.clone()) + .route("/{*rest}", Method::OPTIONS, get_fallback.clone()) .get("/{*rest}", get_fallback) .post("/{*rest}", post_fallback) .build() diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index e26caaf1f..14268fb95 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -16,7 +16,8 @@ use async_trait::async_trait; use edgezero_adapter_fastly::FastlyRequestContext; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderName, HeaderValue, Response}; +use edgezero_core::http::{HeaderName, HeaderValue, Response, StatusCode}; +use edgezero_core::response::IntoResponse; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::{ @@ -61,12 +62,23 @@ impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); - let geo_info = self.geo.lookup(client_ip).unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); + let mut response = match next.run(ctx).await { + Ok(r) => r, + Err(e) => { + log::error!("request handler failed: {e:?}"); + e.into_response() + } + }; + + // Skip geo lookup for authentication rejections — the lookup is unnecessary for 401s. + let geo_info = if response.status() != StatusCode::UNAUTHORIZED { + self.geo.lookup(client_ip).unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }) + } else { None - }); - - let mut response = next.run(ctx).await?; + }; apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); From 6c016d8268eba8060ccdc1837e98b9b204310a12 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 21 Apr 2026 11:00:00 +0530 Subject: [PATCH 18/22] =?UTF-8?q?Make=20AppState=20private=20=E2=80=94=20n?= =?UTF-8?q?ot=20part=20of=20any=20public=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/trusted-server-adapter-fastly/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d626d7504..86248ede5 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -70,7 +70,7 @@ use crate::platform::{ /// /// In Fastly Compute each request spawns a new Wasm instance, so this struct is /// effectively per-request. It holds pre-parsed settings and all service handles. -pub struct AppState { +struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, From eb6a9e9fd37a7ec52d2bfe7fc9f43db2d43583f8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Apr 2026 18:50:25 +0530 Subject: [PATCH 19/22] Resolve review findings --- .../trusted-server-adapter-fastly/src/app.rs | 369 ++++++++++-------- .../trusted-server-adapter-fastly/src/main.rs | 95 +---- .../src/middleware.rs | 17 +- crates/trusted-server-core/src/proxy.rs | 227 ++++++++++- fastly.toml | 2 + 5 files changed, 428 insertions(+), 282 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 86248ede5..d3130f529 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -2,7 +2,8 @@ //! //! Registers all routes from the legacy [`crate::route_request`] into a //! [`RouterService`], attaches [`FinalizeResponseMiddleware`] (outermost) and -//! [`AuthMiddleware`] (inner), and builds the [`AppState`] once at startup. +//! [`AuthMiddleware`] (inner), and builds the [`AppState`] once per Wasm +//! instance. //! //! # Route inventory //! @@ -18,10 +19,8 @@ //! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] | //! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] | //! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | -//! | GET | `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | -//! | POST | `/{*rest}` | integration proxy or publisher fallback | -//! | HEAD | `/{*rest}` | publisher fallback (cache validation) | -//! | OPTIONS | `/{*rest}` | publisher fallback (CORS preflight) | +//! | GET | `/` and `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | +//! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | `/` and `/{*rest}` | integration proxy or publisher fallback | //! //! # Startup error handling //! @@ -29,13 +28,14 @@ //! that responds to all routes with the startup error. This router does **not** //! attach middleware — startup errors are returned without geo or TS headers. +use core::future::Future; use std::sync::Arc; use edgezero_adapter_fastly::FastlyRequestContext; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{header, HeaderValue, Method, Response}; +use edgezero_core::http::{header, HeaderValue, Method, Request, Response}; use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; @@ -70,11 +70,11 @@ use crate::platform::{ /// /// In Fastly Compute each request spawns a new Wasm instance, so this struct is /// effectively per-request. It holds pre-parsed settings and all service handles. -struct AppState { - settings: Arc, - orchestrator: Arc, - registry: Arc, - kv_store: Arc, +pub(crate) struct AppState { + pub(crate) settings: Arc, + pub(crate) orchestrator: Arc, + pub(crate) registry: Arc, + pub(crate) kv_store: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -83,7 +83,7 @@ struct AppState { /// /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. -fn build_state() -> Result, Report> { +pub(crate) fn build_state() -> Result, Report> { let settings = get_settings()?; let orchestrator = build_orchestrator(&settings)?; @@ -136,6 +136,66 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .build() } +fn publisher_fallback_methods() -> [Method; 7] { + [ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + ] +} + +fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool { + *method == Method::GET && path.starts_with("/static/tsjs=") +} + +async fn execute_handler( + state: Arc, + ctx: RequestContext, + handler: F, +) -> Result +where + F: FnOnce(Arc, RuntimeServices, Request) -> Fut, + Fut: Future>>, +{ + let services = build_per_request_services(&state, &ctx); + let req = ctx.into_request(); + + Ok(handler(state, services, req) + .await + .unwrap_or_else(|e| http_error(&e))) +} + +async fn dispatch_fallback( + state: &AppState, + services: &RuntimeServices, + req: Request, +) -> Result> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + if uses_dynamic_tsjs_fallback(&method, &path) { + return handle_tsjs_dynamic(&req, &state.registry); + } + + if state.registry.has_route(&method, &path) { + return state + .registry + .handle_proxy(&method, &path, &state.settings, services, req) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }); + } + + handle_publisher_request(&state.settings, &state.registry, services, req).await +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -164,7 +224,7 @@ pub(crate) fn http_error(report: &Report) -> Response { // Startup error fallback // --------------------------------------------------------------------------- -/// Returns a [`RouterService`] that responds to every route with the startup error. +/// Returns a [`RouterService`] that responds to every registered route with the startup error. /// /// Called when [`build_state`] fails so that request handling degrades to a /// structured HTTP error response rather than an unrecoverable panic. @@ -185,12 +245,12 @@ fn startup_error_router(e: &Report) -> RouterService { } }; - RouterService::builder() - .get("/", make(Arc::clone(&message))) - .post("/", make(Arc::clone(&message))) - .get("/{*rest}", make(Arc::clone(&message))) - .post("/{*rest}", make(Arc::clone(&message))) - .build() + let mut router = RouterService::builder(); + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), make(Arc::clone(&message))); + router = router.route("/{*rest}", method, make(Arc::clone(&message))); + } + router.build() } // --------------------------------------------------------------------------- @@ -214,198 +274,97 @@ impl Hooks for TrustedServerApp { } }; - // Each handler below follows the same pattern: clone state, build - // per-request services, consume the context into the request, call the - // core handler, and convert errors with http_error. The pattern is kept - // explicit rather than abstracted into a macro so each route can be - // audited in isolation and handlers with differing signatures (sync vs - // async, extra orchestrator argument) remain readable without special-casing. - - // /.well-known/trusted-server.json + // Each named route only selects its core handler; the request/context + // scaffolding and Report -> HTTP mapping live in execute_handler(). let s = Arc::clone(&state); let discovery_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_trusted_server_discovery(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_trusted_server_discovery(&state.settings, &services, req) + }) }; - // /verify-signature let s = Arc::clone(&state); let verify_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_verify_signature(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_verify_signature(&state.settings, &services, req) + }) }; - // /admin/keys/rotate let s = Arc::clone(&state); let rotate_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_rotate_key(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_rotate_key(&state.settings, &services, req) + }) }; - // /admin/keys/deactivate let s = Arc::clone(&state); let deactivate_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_deactivate_key(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_deactivate_key(&state.settings, &services, req) + }) }; - // /auction let s = Arc::clone(&state); let auction_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_auction(&s.settings, &s.orchestrator, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_auction(&state.settings, &state.orchestrator, &services, req).await + }) }; - // /first-party/proxy let s = Arc::clone(&state); let fp_proxy_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_first_party_proxy(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_first_party_proxy(&state.settings, &services, req).await + }) }; - // /first-party/click let s = Arc::clone(&state); let fp_click_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_first_party_click(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_first_party_click(&state.settings, &services, req).await + }) }; - // GET /first-party/sign let s = Arc::clone(&state); let fp_sign_get_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_first_party_proxy_sign(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_first_party_proxy_sign(&state.settings, &services, req).await + }) }; - // POST /first-party/sign let s = Arc::clone(&state); let fp_sign_post_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok(handle_first_party_proxy_sign(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_first_party_proxy_sign(&state.settings, &services, req).await + }) }; - // /first-party/proxy-rebuild let s = Arc::clone(&state); let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - Ok( - handle_first_party_proxy_rebuild(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e)), - ) - } + execute_handler(s, ctx, |state, services, req| async move { + handle_first_party_proxy_rebuild(&state.settings, &services, req).await + }) }; - // GET /{*rest} — tsjs (if /static/tsjs= prefix), integration proxy, or publisher fallback let s = Arc::clone(&state); - let get_fallback = move |ctx: RequestContext| { + let fallback_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - let path = req.uri().path().to_string(); - let method = req.method().clone(); - - let result = if path.starts_with("/static/tsjs=") { - handle_tsjs_dynamic(&req, &s.registry) - } else if s.registry.has_route(&method, &path) { - s.registry - .handle_proxy(&method, &path, &s.settings, &services, req) - .await - .unwrap_or_else(|| { - Err(Report::new(TrustedServerError::BadRequest { - message: format!("Unknown integration route: {path}"), - })) - }) - } else { - handle_publisher_request(&s.settings, &s.registry, &services, req).await - }; - - Ok(result.unwrap_or_else(|e| http_error(&e))) - } - }; - - // POST /{*rest} — integration proxy or publisher origin fallback - let s = Arc::clone(&state); - let post_fallback = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = build_per_request_services(&s, &ctx); - let req = ctx.into_request(); - let path = req.uri().path().to_string(); - let method = req.method().clone(); - - let result = if s.registry.has_route(&method, &path) { - s.registry - .handle_proxy(&method, &path, &s.settings, &services, req) - .await - .unwrap_or_else(|| { - Err(Report::new(TrustedServerError::BadRequest { - message: format!("Unknown integration route: {path}"), - })) - }) - } else { - handle_publisher_request(&s.settings, &s.registry, &services, req).await - }; - - Ok(result.unwrap_or_else(|e| http_error(&e))) - } + execute_handler(s, ctx, |state, services, req| async move { + dispatch_fallback(&state, &services, req).await + }) }; - RouterService::builder() + let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new( Arc::clone(&state.settings), Arc::new(FastlyPlatformGeo), @@ -420,18 +379,88 @@ impl Hooks for TrustedServerApp { .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_get_handler) .post("/first-party/sign", fp_sign_post_handler) - .post("/first-party/proxy-rebuild", fp_rebuild_handler) - // matchit's `/{*rest}` does not match the bare root `/` — register - // explicit root routes so `/` reaches the publisher fallback too. - .get("/", get_fallback.clone()) - .post("/", post_fallback.clone()) - // HEAD and OPTIONS reach the publisher origin for cache validation and CORS preflight. - .route("/", Method::HEAD, get_fallback.clone()) - .route("/{*rest}", Method::HEAD, get_fallback.clone()) - .route("/", Method::OPTIONS, get_fallback.clone()) - .route("/{*rest}", Method::OPTIONS, get_fallback.clone()) - .get("/{*rest}", get_fallback) - .post("/{*rest}", post_fallback) - .build() + .post("/first-party/proxy-rebuild", fp_rebuild_handler); + + // matchit's `/{*rest}` does not match the bare root `/` — register + // explicit root routes so `/` reaches the publisher fallback too. + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), fallback_handler.clone()); + router = router.route("/{*rest}", method, fallback_handler.clone()); + } + + router.build() + } +} + +#[cfg(test)] +mod tests { + use super::startup_error_router; + + use edgezero_core::body::Body; + use edgezero_core::http::{header, request_builder, Method, StatusCode}; + use error_stack::Report; + use futures::executor::block_on; + use trusted_server_core::error::TrustedServerError; + + fn empty_request(method: Method, uri: &str) -> edgezero_core::http::Request { + request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("should build request") + } + + #[test] + fn startup_error_router_handles_head_and_options() { + let report = Report::new(TrustedServerError::BadRequest { + message: "startup failed".to_string(), + }); + let router = startup_error_router(&report); + + let head_response = block_on(router.oneshot(empty_request(Method::HEAD, "/"))); + let options_response = block_on(router.oneshot(empty_request(Method::OPTIONS, "/any"))); + + assert_eq!( + head_response.status(), + StatusCode::BAD_REQUEST, + "HEAD should use the degraded startup-error response" + ); + assert_eq!( + options_response.status(), + StatusCode::BAD_REQUEST, + "OPTIONS should use the degraded startup-error response" + ); + assert_eq!( + head_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8"), + "startup errors should stay plain-text for HEAD requests" + ); + assert_eq!( + options_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8"), + "startup errors should stay plain-text for OPTIONS requests" + ); + } + + #[test] + fn dynamic_tsjs_fallback_is_get_only() { + assert!( + super::uses_dynamic_tsjs_fallback(&Method::GET, "/static/tsjs=tsjs-unified.js"), + "GET should use the dynamic tsjs shortcut" + ); + assert!( + !super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"), + "HEAD should fall through to the publisher/integration fallback" + ); + assert!( + !super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"), + "OPTIONS should fall through to the publisher/integration fallback" + ); } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 0819ee0e9..0488370d9 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,19 +1,15 @@ use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::{ - header, HeaderName, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, + header, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, }; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Error, Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::auction::AuctionOrchestrator; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::compat; -use trusted_server_core::constants::{ - ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, - HEADER_X_TS_ENV, HEADER_X_TS_VERSION, -}; use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; use trusted_server_core::geo::GeoInfo; use trusted_server_core::integrations::IntegrationRegistry; @@ -28,7 +24,6 @@ use trusted_server_core::request_signing::{ handle_verify_signature, }; use trusted_server_core::settings::Settings; -use trusted_server_core::settings_data::get_settings; mod app; mod error; @@ -37,9 +32,10 @@ mod management_api; mod middleware; mod platform; -use crate::app::TrustedServerApp; +use crate::app::{build_state, TrustedServerApp}; use crate::error::to_error_response; -use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; +use crate::middleware::apply_finalize_headers; +use crate::platform::build_runtime_services; use edgezero_core::app::Hooks as _; /// Returns `true` if the raw config-store value represents an enabled flag. @@ -116,58 +112,25 @@ fn main(mut req: FastlyRequest) -> Result { /// Propagates [`fastly::Error`] from the Fastly SDK. // TODO: delete after Phase 5 EdgeZero cutover — see issue #495 fn legacy_main(mut req: FastlyRequest) -> Result { - let settings = match get_settings() { - Ok(s) => s, + let state = match build_state() { + Ok(state) => state, Err(e) => { - log::error!("Failed to load settings: {:?}", e); + log::error!("Failed to build application state: {:?}", e); return Ok(to_error_response(&e)); } }; - log::debug!("Settings {settings:?}"); - - // Build the auction orchestrator once at startup - let orchestrator = match build_orchestrator(&settings) { - Ok(orchestrator) => orchestrator, - Err(e) => { - log::error!("Failed to build auction orchestrator: {:?}", e); - return Ok(to_error_response(&e)); - } - }; - - let integration_registry = match IntegrationRegistry::new(&settings) { - Ok(r) => r, - Err(e) => { - log::error!("Failed to create integration registry: {:?}", e); - return Ok(to_error_response(&e)); - } - }; - - let kv_store = match open_kv_store(&settings.synthetic.opid_store) { - Ok(s) => s, - Err(e) => { - // Degrade gracefully: routes that do not touch synthetic IDs - // (e.g. /.well-known/, /verify-signature, /admin/keys/*) must - // still succeed even when the KV store is unavailable. - // Handlers that call kv_handle() will receive KvError::Unavailable. - log::warn!( - "KV store '{}' unavailable, synthetic ID routes will return errors: {e}", - settings.synthetic.opid_store - ); - std::sync::Arc::new(UnavailableKvStore) - as std::sync::Arc - } - }; + log::debug!("Settings {:?}", state.settings); // Strip client-spoofable forwarded headers at the edge before building // any request-derived context or converting to the core HTTP types. compat::sanitize_fastly_forwarded_headers(&mut req); - let runtime_services = build_runtime_services(&req, kv_store); + let runtime_services = build_runtime_services(&req, std::sync::Arc::clone(&state.kv_store)); let http_req = compat::from_fastly_request(req); let mut response = futures::executor::block_on(route_request( - &settings, - &orchestrator, - &integration_registry, + &state.settings, + &state.orchestrator, + &state.registry, &runtime_services, http_req, )) @@ -185,7 +148,7 @@ fn legacy_main(mut req: FastlyRequest) -> Result { }) }; - finalize_response(&settings, geo_info.as_ref(), &mut response); + finalize_response(&state.settings, geo_info.as_ref(), &mut response); Ok(compat::to_fastly_response(response)) } @@ -282,35 +245,7 @@ async fn route_request( /// version/staging, then operator-configured `settings.response_headers`. /// This means operators can intentionally override any managed header. fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { - if let Some(geo) = geo_info { - geo.set_response_headers(response); - } else { - response.headers_mut().insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("false"), - ); - } - - if let Ok(v) = ::std::env::var(ENV_FASTLY_SERVICE_VERSION) { - if let Ok(value) = HeaderValue::from_str(&v) { - response.headers_mut().insert(HEADER_X_TS_VERSION, value); - } else { - log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); - } - } - if ::std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { - response - .headers_mut() - .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); - } - - for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()) - .expect("settings.response_headers validated at load time"); - let header_value = - HeaderValue::from_str(value).expect("settings.response_headers validated at load time"); - response.headers_mut().insert(header_name, header_value); - } + apply_finalize_headers(settings, geo_info, response); } fn http_error_response(report: &Report) -> HttpResponse { diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 14268fb95..3130665d5 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -17,8 +17,8 @@ use edgezero_adapter_fastly::FastlyRequestContext; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderName, HeaderValue, Response, StatusCode}; -use edgezero_core::response::IntoResponse; use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, @@ -172,16 +172,11 @@ pub(crate) fn apply_finalize_headers( } for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()); - let header_value = HeaderValue::from_str(value); - if let (Ok(header_name), Ok(header_value)) = (header_name, header_value) { - response.headers_mut().insert(header_name, header_value); - } else { - log::warn!( - "Skipping invalid configured response header value for {}", - key - ); - } + let header_name = HeaderName::from_bytes(key.as_bytes()) + .expect("settings.response_headers validated at load time"); + let header_value = + HeaderValue::from_str(value).expect("settings.response_headers validated at load time"); + response.headers_mut().insert(header_name, header_value); } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 2eca85a09..87e6dd62e 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -413,6 +413,12 @@ struct ProxyRequestHeaders<'a> { services: &'a RuntimeServices, } +struct ProxyRedirectPolicy<'a> { + follow_redirects: bool, + stream_passthrough: bool, + allowed_domains: &'a [String], +} + /// Proxy a request to a clear target URL while reusing creative rewrite logic. /// /// This forwards a curated header set, follows redirects when enabled, and can append @@ -437,7 +443,7 @@ pub async fn proxy_request( headers, copy_request_headers, stream_passthrough, - allowed_domains: _, + allowed_domains, } = config; let mut target_url_parsed = url::Url::parse(target_url).map_err(|_| { @@ -454,14 +460,17 @@ pub async fn proxy_request( settings, &req, target_url_parsed, - follow_redirects, body.as_deref(), ProxyRequestHeaders { additional_headers: &headers, copy_request_headers, services, }, - stream_passthrough, + ProxyRedirectPolicy { + follow_redirects, + stream_passthrough, + allowed_domains, + }, ) .await } @@ -539,10 +548,9 @@ async fn proxy_with_redirects( settings: &Settings, req: &Request, target_url_parsed: url::Url, - follow_redirects: bool, body: Option<&[u8]>, request_headers: ProxyRequestHeaders<'_>, - stream_passthrough: bool, + redirect_policy: ProxyRedirectPolicy<'_>, ) -> Result, Report> { const MAX_REDIRECTS: usize = 4; @@ -570,7 +578,7 @@ async fn proxy_with_redirects( })); } - if !redirect_is_permitted(&settings.proxy.allowed_domains, host) { + if !redirect_is_permitted(redirect_policy.allowed_domains, host) { log::warn!( "request to `{}` blocked: host not in proxy allowed_domains", host @@ -635,8 +643,14 @@ async fn proxy_with_redirects( let beresp = platform_resp.response; - if !follow_redirects { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + if !redirect_policy.follow_redirects { + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); } let status = beresp.status(); @@ -650,7 +664,13 @@ async fn proxy_with_redirects( ); if !is_redirect { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); } let Some(location) = beresp @@ -659,7 +679,13 @@ async fn proxy_with_redirects( .and_then(|h| h.to_str().ok()) .filter(|value| !value.is_empty()) else { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); }; if redirect_attempt == MAX_REDIRECTS { @@ -680,7 +706,13 @@ async fn proxy_with_redirects( let next_scheme = next_url.scheme().to_ascii_lowercase(); if next_scheme != "http" && next_scheme != "https" { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); } let next_host = match next_url.host_str() { @@ -691,7 +723,7 @@ async fn proxy_with_redirects( })); } }; - if !redirect_is_permitted(&settings.proxy.allowed_domains, next_host) { + if !redirect_is_permitted(redirect_policy.allowed_domains, next_host) { log::warn!( "redirect to `{}` blocked: host not in proxy allowed_domains", next_host @@ -1292,6 +1324,9 @@ fn reconstruct_and_validate_signed_target( #[cfg(test)] mod tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use super::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, proxy_request, rebuild_response_with_body, @@ -1300,7 +1335,11 @@ mod tests { use crate::constants::HEADER_ACCEPT; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; - use crate::platform::test_support::noop_services; + use crate::platform::test_support::{build_services_with_http_client, noop_services}; + use crate::platform::{ + PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, + PlatformResponse, PlatformSelectResult, + }; use crate::test_support::tests::create_test_settings; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -1365,6 +1404,79 @@ mod tests { .expect("response body should be valid UTF-8") } + struct QueuedHttpResponse { + status: u16, + headers: Vec<(header::HeaderName, HeaderValue)>, + body: Vec, + } + + #[derive(Default)] + struct HeaderAwareStubHttpClient { + responses: Mutex>, + } + + impl HeaderAwareStubHttpClient { + fn new() -> Self { + Self::default() + } + + fn push_response( + &self, + status: u16, + headers: Vec<(header::HeaderName, HeaderValue)>, + body: Vec, + ) { + self.responses + .lock() + .expect("should lock queued responses") + .push_back(QueuedHttpResponse { + status, + headers, + body, + }); + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for HeaderAwareStubHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + let queued = self + .responses + .lock() + .expect("should lock queued responses") + .pop_front() + .ok_or_else(|| Report::new(PlatformError::HttpClient))?; + + let mut builder = edgezero_core::http::response_builder().status(queued.status); + for (name, value) in queued.headers { + builder = builder.header(name, value); + } + + let response = builder + .body(EdgeBody::from(queued.body)) + .expect("should build stub HTTP response"); + + Ok(PlatformResponse::new(response)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + } + fn build_http_response(status: StatusCode, body: EdgeBody) -> Response { let mut response = Response::new(body); *response.status_mut() = status; @@ -2062,8 +2174,7 @@ mod tests { #[tokio::test] async fn proxy_request_calls_platform_http_client_send() { - use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; - use std::sync::Arc; + use crate::platform::test_support::StubHttpClient; let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"ok".to_vec()); @@ -2099,6 +2210,83 @@ mod tests { ); } + #[tokio::test] + async fn proxy_request_allows_open_mode_when_settings_allowlist_is_non_empty() { + let mut settings = create_test_settings(); + settings.proxy.allowed_domains = vec!["allowed.example".to_string()]; + + let stub = Arc::new(HeaderAwareStubHttpClient::new()); + stub.push_response(200, Vec::new(), b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = build_http_request(Method::GET, "https://edge.example/"); + + let response = proxy_request( + &settings, + req, + ProxyRequestConfig { + target_url: "https://blocked.example/resource.js", + follow_redirects: false, + forward_synthetic_id: false, + body: None, + headers: Vec::new(), + copy_request_headers: false, + stream_passthrough: false, + allowed_domains: &[], + }, + &services, + ) + .await + .expect("open mode should ignore settings.proxy.allowed_domains"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "ok"); + } + + #[tokio::test] + async fn proxy_request_uses_config_allowlist_for_redirect_hops() { + let mut settings = create_test_settings(); + settings.proxy.allowed_domains = vec!["origin.example".to_string()]; + + let stub = Arc::new(HeaderAwareStubHttpClient::new()); + stub.push_response( + 302, + vec![( + header::LOCATION, + HeaderValue::from_static("https://redirected.example/final.js"), + )], + Vec::new(), + ); + stub.push_response(200, Vec::new(), b"redirected".to_vec()); + + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = build_http_request(Method::GET, "https://edge.example/"); + + let response = proxy_request( + &settings, + req, + ProxyRequestConfig { + target_url: "https://origin.example/start.js", + follow_redirects: true, + forward_synthetic_id: false, + body: None, + headers: Vec::new(), + copy_request_headers: false, + stream_passthrough: false, + allowed_domains: &[], + }, + &services, + ) + .await + .expect("open mode should allow redirect hops outside settings allowlist"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "redirected"); + } + // --- is_host_allowed --- #[test] @@ -2314,12 +2502,9 @@ mod tests { // --- initial target allowlist enforcement (integration-level) --- // - // NOTE: A test for Nth-hop redirect blocking (i.e. exercising the - // `redirect_is_permitted` check that fires *after* receiving a 302 - // response) requires a Viceroy backend fixture that returns a redirect. - // That infrastructure is not available here. The unit tests above for - // `redirect_is_permitted` and `ip_literal_blocked_by_domain_allowlist` - // cover the blocking logic used at every hop. + // The unit tests above cover the host-matching logic itself. The tests + // below verify that proxy_request threads config.allowed_domains through + // the initial target check and redirect hops. #[tokio::test] async fn proxy_initial_target_blocked_by_allowlist() { diff --git a/fastly.toml b/fastly.toml index 425eacbb3..46e123005 100644 --- a/fastly.toml +++ b/fastly.toml @@ -51,6 +51,8 @@ build = """ [local_server.config_stores.trusted_server_config] format = "inline-toml" [local_server.config_stores.trusted_server_config.contents] + # "true" / "1" enable the EdgeZero path. Missing, unreadable, or + # any other value falls back to the legacy entry point. edgezero_enabled = "true" [local_server.config_stores.jwks_store] From fb8beac0e148e4209b6fd43e3311800e440c7e51 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 27 Apr 2026 16:50:45 +0530 Subject: [PATCH 20/22] fix cargo fmt --- .../trusted-server-adapter-fastly/src/main.rs | 24 +++++++++------- .../src/route_tests.rs | 2 +- crates/trusted-server-core/src/edge_cookie.rs | 15 ++++++---- .../src/integrations/prebid.rs | 28 ++++++++++--------- .../src/integrations/registry.rs | 3 +- crates/trusted-server-core/src/proxy.rs | 23 +++++++++------ crates/trusted-server-core/src/publisher.rs | 16 ++++++++--- 7 files changed, 67 insertions(+), 44 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 0b3d388db..cae73c35f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -241,13 +241,16 @@ async fn route_request( ); match runtime_services_for_consent_route(settings, runtime_services) { - Ok(publisher_services) => { - handle_publisher_request(settings, integration_registry, &publisher_services, req) - .await - .and_then(|pub_response| { - resolve_publisher_response(pub_response, settings, integration_registry) - }) - } + Ok(publisher_services) => handle_publisher_request( + settings, + integration_registry, + &publisher_services, + req, + ) + .await + .and_then(|pub_response| { + resolve_publisher_response(pub_response, settings, integration_registry) + }), Err(e) => Err(e), } } @@ -268,9 +271,10 @@ pub(crate) fn resolve_publisher_response( } => { let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, settings, integration_registry)?; - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(output.len() as u64)); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(output.len() as u64), + ); *response.body_mut() = EdgeBody::from(output); Ok(response) } diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 001f70f70..aa1843a25 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -7,6 +7,7 @@ use fastly::http::StatusCode; use fastly::Request; use trusted_server_core::auction::build_orchestrator; use trusted_server_core::compat; +use trusted_server_core::error::IntoHttpResponse; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -14,7 +15,6 @@ use trusted_server_core::platform::{ PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; -use trusted_server_core::error::IntoHttpResponse; use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME; use trusted_server_core::settings::Settings; diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 2d796338b..c1b451652 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -109,7 +109,11 @@ pub fn generate_ec_id( /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails pub fn get_ec_id(req: &Request) -> Result, Report> { - if let Some(ec_id) = req.headers().get(HEADER_X_TS_EC).and_then(|h| h.to_str().ok()) { + if let Some(ec_id) = req + .headers() + .get(HEADER_X_TS_EC) + .and_then(|h| h.to_str().ok()) + { if ec_id_has_only_allowed_chars(ec_id) { log::trace!("Using existing EC ID from header: {}", ec_id); return Ok(Some(ec_id.to_string())); @@ -207,7 +211,9 @@ mod tests { for (key, value) in headers { builder = builder.header(key, *value); } - builder.body(EdgeBody::empty()).expect("should build test request") + builder + .body(EdgeBody::empty()) + .expect("should build test request") } fn is_ec_id_format(value: &str) -> bool { @@ -352,10 +358,7 @@ mod tests { fn test_get_ec_id_rejects_invalid_header_and_falls_back_to_cookie() { let req = create_test_request(&[ (HEADER_X_TS_EC, "evil;injected"), - ( - header::COOKIE, - &format!("{}=valid_cookie_id", COOKIE_TS_EC), - ), + (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), ]); let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index ca33befb7..3a9a96d04 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1057,21 +1057,23 @@ impl AuctionProvider for PrebidAuctionProvider { let request_info = RequestInfo::from_request(context.request, context.client_info); // Create signer and compute signature if request signing is enabled - let signer_with_signature = if let Some(request_signing_config) = - &context.settings.request_signing - { - if request_signing_config.enabled { - let signer = RequestSigner::from_services(context.services)?; - let params = - SigningParams::new(request.id.clone(), request_info.host.clone(), request_info.scheme.clone()); - let signature = signer.sign_request(¶ms)?; - Some((signer, signature, params)) + let signer_with_signature = + if let Some(request_signing_config) = &context.settings.request_signing { + if request_signing_config.enabled { + let signer = RequestSigner::from_services(context.services)?; + let params = SigningParams::new( + request.id.clone(), + request_info.host.clone(), + request_info.scheme.clone(), + ); + let signature = signer.sign_request(¶ms)?; + Some((signer, signature, params)) + } else { + None + } } else { None - } - } else { - None - }; + }; // Convert to OpenRTB with all enrichments let openrtb = self.to_openrtb( diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 2f98d12aa..c02f89857 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -673,7 +673,8 @@ impl IntegrationRegistry { if let Ok(ref ec_id) = ec_id_result { match HeaderValue::from_str(ec_id) { Ok(header_value) => { - req.headers_mut().insert(HEADER_X_TS_EC.clone(), header_value); + req.headers_mut() + .insert(HEADER_X_TS_EC.clone(), header_value); } Err(error) => { log::warn!("Failed to build x-ts-ec request header value: {}", error); diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 323330832..30fe1434e 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -41,7 +41,6 @@ const PROXY_FORWARD_HEADERS: [header::HeaderName; 5] = [ HEADER_X_FORWARDED_FOR, ]; - #[derive(Deserialize)] struct ProxySignReq { url: String, @@ -2331,8 +2330,12 @@ mod tests { .uri("https://example.com/") .body(EdgeBody::empty()) .expect("should build test request"); - req.headers_mut().insert(header::USER_AGENT, HeaderValue::from_static("test-agent/1.0")); - req.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("text/html")); + req.headers_mut().insert( + header::USER_AGENT, + HeaderValue::from_static("test-agent/1.0"), + ); + req.headers_mut() + .insert(header::ACCEPT, HeaderValue::from_static("text/html")); req.headers_mut() .insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US")); @@ -2431,12 +2434,14 @@ mod tests { fn rebuild_response_with_body_preserves_multiple_set_cookie_headers() { let mut beresp = Response::new(EdgeBody::empty()); *beresp.status_mut() = StatusCode::OK; - beresp - .headers_mut() - .append(header::SET_COOKIE, HeaderValue::from_static("a=1; Path=/; Secure")); - beresp - .headers_mut() - .append(header::SET_COOKIE, HeaderValue::from_static("b=2; Path=/; Secure")); + beresp.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static("a=1; Path=/; Secure"), + ); + beresp.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static("b=2; Path=/; Secure"), + ); let rebuilt = rebuild_response_with_body( beresp, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ca2caaf36..4582a3344 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -692,7 +692,10 @@ pub async fn handle_publisher_request( let mut output = Vec::new(); process_response_streaming(body, &mut output, ¶ms)?; - response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from(output.len() as u64)); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(output.len() as u64), + ); *response.body_mut() = EdgeBody::from(output); Ok(PublisherResponse::Buffered(response)) @@ -1557,8 +1560,14 @@ mod tests { }; let mut output = Vec::new(); - stream_publisher_body(EdgeBody::empty(), &mut output, ¶ms, &settings, ®istry) - .expect("should succeed on empty body"); + stream_publisher_body( + EdgeBody::empty(), + &mut output, + ¶ms, + &settings, + ®istry, + ) + .expect("should succeed on empty body"); assert!( output.is_empty(), @@ -1764,7 +1773,6 @@ mod tests { assert!( !processed.contains("origin.example.com"), "origin host must not leak. Got: {processed}" - ); } } From a94ddd877170b2a4a047a800b2c4bb3c9183105d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 27 Apr 2026 17:32:33 +0530 Subject: [PATCH 21/22] resolve PR review findings --- .../trusted-server-adapter-fastly/src/main.rs | 27 +++- .../src/middleware.rs | 145 +++++++++++++++++- crates/trusted-server-core/src/proxy.rs | 19 ++- 3 files changed, 182 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index cae73c35f..2876667cf 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -13,6 +13,7 @@ use trusted_server_core::compat; use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; use trusted_server_core::geo::GeoInfo; use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -26,6 +27,7 @@ use trusted_server_core::request_signing::{ handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::get_settings; mod app; mod error; @@ -39,7 +41,7 @@ mod route_tests; use crate::app::{build_state, TrustedServerApp}; use crate::error::to_error_response; use crate::middleware::apply_finalize_headers; -use crate::platform::{build_runtime_services, open_kv_store}; +use crate::platform::{build_runtime_services, open_kv_store, FastlyPlatformGeo}; use edgezero_core::app::Hooks as _; /// Returns `true` if the raw config-store value represents an enabled flag. @@ -86,18 +88,35 @@ fn main(mut req: FastlyRequest) -> Result { log::warn!("failed to read edgezero_enabled flag, falling back to legacy path: {e}"); false }) { - log::info!("routing request through EdgeZero path"); + log::debug!("routing request through EdgeZero path"); let app = TrustedServerApp::build_app(); // Strip client-spoofable forwarded headers before handing off to the // EdgeZero dispatcher, mirroring the sanitization done in legacy_main. compat::sanitize_fastly_forwarded_headers(&mut req); + // Capture client IP before the request is consumed by dispatch. + let client_ip = req.get_client_ip_addr(); // `run_app_with_config` and `run_app_with_logging` call `init_logger` // internally — a second `set_logger` call panics because our custom // fern logger is already initialised above. `dispatch_with_config` // skips logger initialisation and injects the config store directly. - edgezero_adapter_fastly::dispatch_with_config(&app, req, "trusted_server_config") + let mut response = compat::from_fastly_response( + edgezero_adapter_fastly::dispatch_with_config(&app, req, "trusted_server_config")?, + ); + // Apply finalize headers at the entry point so that router-level 405/404 + // responses for unregistered HTTP methods (e.g. TRACE, WebDAV verbs) + // carry TS/geo headers, matching the legacy path's all-methods guarantee. + // For requests that ran through FinalizeResponseMiddleware, this is + // idempotent — header::insert overwrites with the same values. + if let Ok(settings) = get_settings() { + let geo_info = FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + log::warn!("entry-point geo lookup failed: {e}"); + None + }); + apply_finalize_headers(&settings, geo_info.as_ref(), &mut response); + } + Ok(compat::to_fastly_response(response)) } else { - log::info!("routing request through legacy path"); + log::debug!("routing request through legacy path"); legacy_main(req) } } diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 3130665d5..1e7d0ba52 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -35,9 +35,13 @@ use trusted_server_core::settings::Settings; /// Outermost middleware: performs geo lookup and injects all standard TS response headers. /// /// Registered first in the middleware chain so that it wraps all inner middleware -/// (including [`AuthMiddleware`]) and the handler. This guarantees every outgoing +/// (including [`AuthMiddleware`]) and the handler. This guarantees every registered-route /// response — including auth-rejected ones — carries a consistent set of headers. /// +/// Router-level 405/404 responses for unregistered HTTP methods (e.g. TRACE) bypass the +/// middleware chain. Those are covered by a second call to [`apply_finalize_headers`] at +/// the `main.rs` entry point, which is idempotent for normal requests. +/// /// # Header precedence /// /// Headers are written in this order (last write wins): @@ -188,8 +192,19 @@ pub(crate) fn apply_finalize_headers( mod tests { use super::*; + use std::collections::HashMap; + use std::net::IpAddr; + use std::sync::Arc; + use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{request_builder, response_builder, Method, StatusCode}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use error_stack::Report; + use futures::executor::block_on; + use trusted_server_core::platform::{PlatformError, PlatformGeo}; fn empty_response() -> Response { response_builder() @@ -197,6 +212,23 @@ mod tests { .expect("should build empty test response") } + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + + struct FixedGeo(Option); + + impl PlatformGeo for FixedGeo { + fn lookup(&self, _: Option) -> Result, Report> { + Ok(self.0.clone()) + } + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { let mut s = trusted_server_core::settings_data::get_settings().expect("should load test settings"); @@ -242,4 +274,113 @@ mod tests { "should set X-Geo-Info-Available: false when no geo info is available" ); } + + // --------------------------------------------------------------------------- + // FinalizeResponseMiddleware::handle tests + // --------------------------------------------------------------------------- + + #[test] + fn finalize_handle_injects_geo_unavailable_on_ok_response() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false when geo returns None" + ); + } + + #[test] + fn finalize_handle_absorbs_handler_error_and_injects_headers() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = Arc::new(|_ctx: RequestContext| async move { + Err::(EdgeError::service_unavailable("test error")) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should absorb handler error into a response"); + + assert!( + response.status().is_server_error(), + "should produce a server-error status for absorbed handler error" + ); + assert!( + response.headers().get("x-geo-info-available").is_some(), + "absorbed error response should still carry geo header" + ); + } + + #[test] + #[allow(clippy::panic)] + fn finalize_handle_skips_geo_lookup_for_401() { + struct PanicGeo; + impl PlatformGeo for PanicGeo { + fn lookup(&self, _: Option) -> Result, Report> { + panic!("should not call geo for 401 responses") + } + } + + let settings = settings_with_response_headers(vec![]); + let middleware = FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(PanicGeo)); + let handler = Arc::new(|_ctx: RequestContext| async move { + let mut resp = empty_response(); + *resp.status_mut() = StatusCode::UNAUTHORIZED; + Ok::(resp) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed without calling geo"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "should preserve 401 status" + ); + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set geo-unavailable header without calling geo for 401" + ); + } + + // --------------------------------------------------------------------------- + // AuthMiddleware::handle tests + // --------------------------------------------------------------------------- + + #[test] + fn auth_handle_passes_through_when_auth_not_configured() { + let settings = + trusted_server_core::settings_data::get_settings().expect("should load test settings"); + let middleware = AuthMiddleware::new(Arc::new(settings)); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should pass through when auth is not configured"); + + assert_eq!( + response.status(), + StatusCode::OK, + "should reach the handler when auth is not required" + ); + } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 30fe1434e..1d5f42ab8 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -71,9 +71,22 @@ pub struct ProxyRequestConfig<'a> { pub stream_passthrough: bool, /// Domains allowed for the initial request and any redirects. /// - /// When empty every host is permitted (open mode). Integration proxies - /// should leave this empty; first-party handlers should pass - /// `&settings.proxy.allowed_domains` to enforce the publisher allowlist. + /// **Open mode** (`&[]`): every host is permitted. Integration proxies pass `&[]` + /// because their target URLs originate from operator-controlled configuration + /// (e.g. `trusted-server.toml` integration settings) and are therefore trusted at + /// operator setup time rather than at request time. + /// + /// **Restricted mode** (non-empty slice): only hosts matching a listed pattern are + /// permitted. First-party proxy handlers pass `&settings.proxy.allowed_domains` + /// because they follow redirect chains that may originate from untrusted + /// creative-supplied URLs. + /// + /// **Behavior change from pre-PR-14**: `proxy_with_redirects` previously always + /// enforced `&settings.proxy.allowed_domains` regardless of the caller. After PR 14, + /// only [`handle_first_party_proxy`] and its siblings enforce the operator allowlist; + /// integration proxies use open mode. This is intentional: applying the operator + /// domain allowlist to integration redirects would require every operator to enumerate + /// every integration CDN in their config, which is impractical. pub allowed_domains: &'a [String], } From b90103766e9e1574b079bb816819822021fb82f0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 27 Apr 2026 17:59:45 +0530 Subject: [PATCH 22/22] Added E2E tests dispatch_auth_rejected_401_carries_finalize_headers, dispatch_unregistered_method_returns_405_at_router_level --- .../trusted-server-adapter-fastly/src/app.rs | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ed2994b46..6123d6546 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -388,12 +388,14 @@ impl Hooks for TrustedServerApp { #[cfg(test)] mod tests { - use super::startup_error_router; + use super::{startup_error_router, TrustedServerApp}; + use edgezero_core::app::Hooks as _; use edgezero_core::body::Body; use edgezero_core::http::{header, request_builder, Method, StatusCode}; use error_stack::Report; use futures::executor::block_on; + use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::error::TrustedServerError; fn empty_request(method: Method, uri: &str) -> edgezero_core::http::Request { @@ -457,4 +459,77 @@ mod tests { "OPTIONS should fall through to the publisher/integration fallback" ); } + + // --------------------------------------------------------------------------- + // Full EdgeZero dispatch-path tests + // --------------------------------------------------------------------------- + + #[test] + fn dispatch_auth_rejected_401_carries_finalize_headers() { + // Verifies FinalizeResponseMiddleware is outermost: an auth-rejected 401 + // must still carry standard TS headers before reaching the client. + // + // The embedded trusted-server.toml protects `^/admin` with basic-auth. + // Sending the request without an Authorization header causes AuthMiddleware + // to short-circuit with a 401, which then bubbles through + // FinalizeResponseMiddleware for header injection. + // + // This is safe to run without Viceroy: enforce_basic_auth is pure Rust + // (reads settings + request headers only) and FastlyPlatformGeo.lookup(None) + // short-circuits without calling any Fastly ABI. + let router = TrustedServerApp::routes(); + let req = request_builder() + .method(Method::POST) + .uri("/admin/keys/rotate") + .body(Body::empty()) + .expect("should build test request"); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "request without credentials should be rejected" + ); + assert_eq!( + response + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) + .and_then(|v| v.to_str().ok()), + Some("false"), + "FinalizeResponseMiddleware must run even for auth-rejected responses" + ); + } + + #[test] + fn dispatch_unregistered_method_returns_405_at_router_level() { + // Documents the known router-level behavior for unregistered HTTP methods: + // the RouterService returns 405 before the middleware chain runs, so + // FinalizeResponseMiddleware does not inject TS headers at this layer. + // + // The full-system guarantee (TS headers on ALL responses) is maintained + // by the entry-point finalize wrap in main.rs, which is idempotent for + // requests that did run through the middleware chain. + let router = TrustedServerApp::routes(); + let req = request_builder() + .method(Method::from_bytes(b"TRACE").expect("should parse TRACE")) + .uri("/") + .body(Body::empty()) + .expect("should build TRACE request"); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "unregistered method should return 405 from the router layer" + ); + assert!( + response + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) + .is_none(), + "router-level 405 bypasses FinalizeResponseMiddleware; main.rs entry-point covers this" + ); + } }