diff --git a/.claude/settings.json b/.claude/settings.json index 02b602d4..b7b0afdc 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/Cargo.lock b/Cargo.lock index 3e162067..186cfecd 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?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?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#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?rev=38198f9839b70aef03ab971ae5876982773fc2a1#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 4a62fd6c..13b15135 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", 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" @@ -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"] } 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 00000000..9150583f --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -0,0 +1,424 @@ +//! 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 | `/{*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::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 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 { + settings: Arc, + orchestrator: Arc, + registry: Arc, + kv_store: Arc, +} + +/// Build the application state, loading settings and constructing all per-application components. +/// +/// # Errors +/// +/// 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)?; + + let registry = IntegrationRegistry::new(&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 + } + }; + + Ok(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. +/// +/// 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. +pub(crate) 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())); + 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 +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +/// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. +pub struct TrustedServerApp; + +impl Hooks for TrustedServerApp { + fn name() -> &'static str { + "TrustedServer" + } + + fn routes() -> RouterService { + 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 + // 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| { + 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))) + } + }; + + // /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))) + } + }; + + // /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))) + } + }; + + // /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))) + } + }; + + // /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))) + } + }; + + // /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))) + } + }; + + // /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))) + } + }; + + // 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))) + } + }; + + // 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))) + } + }; + + // /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)), + ) + } + }; + + // 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 { + 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))) + } + }; + + RouterService::builder() + .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) + .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) + // 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() + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 35ba785c..0819ee0e 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -30,24 +30,92 @@ 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 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. +/// +/// 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.eq_ignore_ascii_case("true") || v == "1" +} + +/// 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(mut req: FastlyRequest) -> Result { - logging::init_logger(); - - // Keep the health probe independent from settings loading and routing so - // readiness checks still get a cheap liveness response during startup. + // 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_else(|e| { + 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(); + // 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` + // 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) + } +} + +/// 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. +// 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, Err(e) => { @@ -258,3 +326,35 @@ 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'" + ); + assert!(parse_edgezero_flag("TRUE"), "should parse uppercase 'TRUE'"); + assert!( + parse_edgezero_flag("True"), + "should parse mixed-case 'True'" + ); + } + + #[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'"); + } +} 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 00000000..e26caaf1 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -0,0 +1,238 @@ +//! 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; +use trusted_server_core::settings::Settings; + +// --------------------------------------------------------------------------- +// 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) +pub struct FinalizeResponseMiddleware { + settings: Arc, + geo: Arc, +} + +impl FinalizeResponseMiddleware { + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings and geo lookup service. + pub fn new(settings: Arc, geo: Arc) -> Self { + Self { settings, geo } + } +} + +#[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 = self.geo.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 convert to a 500 HTTP response. +/// +/// # Errors +/// +/// 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. + 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 Ok(crate::app::http_error(&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 +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" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 77863939..28723da9 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 402810a3..4cd50f0c 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, @@ -402,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 cedc7310..41fd44e1 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}; @@ -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/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 9438c84a..637a3be1 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -280,8 +280,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 61856706..fe6ed281 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() @@ -276,8 +273,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 2f8dba3d..46894d5d 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; @@ -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,); diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index aa14ac72..9196165d 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}; diff --git a/fastly.toml b/fastly.toml index 30665b31..425eacbb 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]