diff --git a/CLAUDE.md b/CLAUDE.md index 49835fe8..32957649 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,7 @@ pub struct UserId(Uuid); - Use concrete error types with `Report`. - Use `ensure!()` / `bail!()` macros for early returns. - Import `Error` from `core::error::` instead of `std::error::`. -- Use `change_context()` to map error types, `attach()` / `attach_with()` for debug info. +- Use `change_context()` to map error types, `attach()` for debug info. - Define errors with `derive_more::Display` (not thiserror): ```rust diff --git a/Cargo.lock b/Cargo.lock index de9dd4ea..01bfa24e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,11 +788,13 @@ dependencies = [ "bytes", "chrono", "edgezero-core", + "fastly", "fern", "flate2", "futures", "futures-util", "log", + "log-fastly", ] [[package]] @@ -2776,8 +2778,10 @@ dependencies = [ name = "trusted-server-adapter-fastly" version = "0.1.0" dependencies = [ + "async-trait", "chrono", "edgezero-adapter-fastly", + "edgezero-core", "error-stack", "fastly", "fern", diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index edd5ab54..7be7c275 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -7,8 +7,10 @@ edition = "2021" workspace = true [dependencies] +async-trait = { workspace = true } chrono = { workspace = true } -edgezero-adapter-fastly = { workspace = true } +edgezero-adapter-fastly = { workspace = true, features = ["fastly"] } +edgezero-core = { workspace = true } error-stack = { workspace = true } fastly = { workspace = true } fern = { workspace = true } @@ -19,3 +21,6 @@ serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { path = "../trusted-server-core" } trusted-server-js = { path = "../js" } + +[dev-dependencies] +edgezero-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d97c8402..226b9708 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -14,6 +14,7 @@ use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -27,7 +28,10 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; mod error; +mod platform; + use crate::error::to_error_response; +use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; #[fastly::main] fn main(req: Request) -> Result { @@ -59,10 +63,28 @@ fn main(req: Request) -> Result { } }; + 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 + } + }; + let runtime_services = build_runtime_services(&req, kv_store); + futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, + &runtime_services, req, )) } @@ -71,6 +93,7 @@ async fn route_request( settings: &Settings, orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, + runtime_services: &RuntimeServices, mut req: Request, ) -> Result { // Strip client-spoofable forwarded headers at the edge. @@ -78,8 +101,15 @@ async fn route_request( // clients are untrusted and can hijack URL rewriting (see #409). sanitize_forwarded_headers(&mut req); - // Extract geo info before auth check or routing consumes the request - let geo_info = GeoInfo::from_request(&req); + // Look up geo info via the platform abstraction using the client IP + // already captured in RuntimeServices at the entry point. + let geo_info = runtime_services + .geo() + .lookup(runtime_services.client_info.client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); if let Some(mut response) = enforce_basic_auth(settings, &req) { finalize_response(settings, geo_info.as_ref(), &mut response); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs new file mode 100644 index 00000000..e7a05f86 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -0,0 +1,416 @@ +//! Fastly-backed implementations of the platform traits defined in +//! `trusted-server-core::platform`. +//! +//! This module also provides [`build_runtime_services`], a free function that +//! constructs a [`RuntimeServices`] instance once at the entry point from the +//! incoming Fastly request. + +use std::net::IpAddr; +use std::sync::Arc; + +use edgezero_adapter_fastly::key_value_store::FastlyKvStore; +use edgezero_core::key_value_store::KvError; +use error_stack::{Report, ResultExt}; +use fastly::geo::geo_lookup; +use fastly::{ConfigStore, Request, SecretStore}; + +use trusted_server_core::backend::BackendConfig; +use trusted_server_core::fastly_storage::FastlyApiClient; +use trusted_server_core::geo::geo_from_fastly; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, + StoreName, +}; + +pub(crate) use trusted_server_core::platform::UnavailableKvStore; + +// --------------------------------------------------------------------------- +// FastlyPlatformConfigStore +// --------------------------------------------------------------------------- + +/// Fastly [`ConfigStore`]-backed implementation of [`PlatformConfigStore`]. +/// +/// Stateless — the store name is supplied per call, matching the trait +/// signature. This replaces the store-name-at-construction pattern of +/// [`trusted_server_core::fastly_storage::FastlyConfigStore`]. +/// +/// # Write cost +/// +/// `put` and `delete` construct a [`FastlyApiClient`] on every call, which +/// opens the `"api-keys"` secret store to read the management API key. On +/// Fastly Compute, the SDK caches the open handle so repeated opens within a +/// single request are cheap. Callers that issue many writes in one request +/// should be aware that each call performs a synchronous outbound API +/// request to the Fastly management API. +pub struct FastlyPlatformConfigStore; + +impl PlatformConfigStore for FastlyPlatformConfigStore { + fn get(&self, store_name: &StoreName, key: &str) -> Result> { + let name = store_name.as_ref(); + let store = ConfigStore::try_open(name).map_err(|e| { + Report::new(PlatformError::ConfigStore) + .attach(format!("failed to open config store '{name}': {e}")) + })?; + store + .try_get(key) + .map_err(|e| { + Report::new(PlatformError::ConfigStore).attach(format!( + "lookup for key '{key}' in config store '{name}' failed: {e}" + )) + })? + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore) + .attach(format!("key '{key}' not found in config store '{name}'")) + }) + } + + fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report> { + FastlyApiClient::new() + .change_context(PlatformError::ConfigStore) + .attach("failed to initialize Fastly API client for config store write")? + .update_config_item(store_id.as_ref(), key, value) + .change_context(PlatformError::ConfigStore) + } + + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { + FastlyApiClient::new() + .change_context(PlatformError::ConfigStore) + .attach("failed to initialize Fastly API client for config store delete")? + .delete_config_item(store_id.as_ref(), key) + .change_context(PlatformError::ConfigStore) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformSecretStore +// --------------------------------------------------------------------------- + +/// Fastly [`SecretStore`]-backed implementation of [`PlatformSecretStore`]. +/// +/// Stateless — the store name is supplied per call. This replaces the +/// store-name-at-construction pattern of +/// [`trusted_server_core::fastly_storage::FastlySecretStore`]. +/// +/// # Write cost +/// +/// `create` and `delete` have the same per-call [`FastlyApiClient`] cost +/// described on [`FastlyPlatformConfigStore`]. +pub struct FastlyPlatformSecretStore; + +impl PlatformSecretStore for FastlyPlatformSecretStore { + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + let name = store_name.as_ref(); + // Unlike ConfigStore::open (which panics), SecretStore::open already + // returns Result — there is no try_open variant on SecretStore. + let store = SecretStore::open(name).map_err(|e| { + Report::new(PlatformError::SecretStore) + .attach(format!("failed to open secret store '{name}': {e}")) + })?; + let secret = store + .try_get(key) + .map_err(|e| { + Report::new(PlatformError::SecretStore).attach(format!( + "lookup for key '{key}' in secret store '{name}' failed: {e}" + )) + })? + .ok_or_else(|| { + Report::new(PlatformError::SecretStore) + .attach(format!("key '{key}' not found in secret store '{name}'")) + })?; + secret + .try_plaintext() + .map(|bytes| bytes.to_vec()) + .map_err(|e| { + Report::new(PlatformError::SecretStore) + .attach(format!("failed to decrypt secret '{key}': {e}")) + }) + } + + fn create( + &self, + store_id: &StoreId, + name: &str, + value: &str, + ) -> Result<(), Report> { + FastlyApiClient::new() + .change_context(PlatformError::SecretStore) + .attach("failed to initialize Fastly API client for secret store create")? + .create_secret(store_id.as_ref(), name, value) + .change_context(PlatformError::SecretStore) + } + + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { + FastlyApiClient::new() + .change_context(PlatformError::SecretStore) + .attach("failed to initialize Fastly API client for secret store delete")? + .delete_secret(store_id.as_ref(), name) + .change_context(PlatformError::SecretStore) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformBackend +// --------------------------------------------------------------------------- + +/// Fastly dynamic-backend implementation of [`PlatformBackend`]. +/// +/// Delegates name computation and registration to [`BackendConfig`], preserving +/// the existing deterministic naming scheme (scheme + host + port + cert + +/// timeout → unique name). +pub struct FastlyPlatformBackend; + +fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { + BackendConfig::new(&spec.scheme, &spec.host) + .port(spec.port) + .certificate_check(spec.certificate_check) + .first_byte_timeout(spec.first_byte_timeout) +} + +impl PlatformBackend for FastlyPlatformBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + backend_config_from_spec(spec) + .predict_name() + .change_context(PlatformError::Backend) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + backend_config_from_spec(spec) + .ensure() + .change_context(PlatformError::Backend) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformHttpClient +// --------------------------------------------------------------------------- + +/// Placeholder Fastly implementation of [`PlatformHttpClient`]. +/// +/// The Fastly-backed `send` / `send_async` / `select` behavior lands in a +/// follow-up PR once the orchestrator migration is complete. Until then all +/// methods return [`PlatformError::Unsupported`]. +/// +/// Implementation lands in #487 (PR 6: Backend + HTTP client traits). +pub struct FastlyPlatformHttpClient; + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for FastlyPlatformHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported) + .attach("FastlyPlatformHttpClient::send is not yet implemented")) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported) + .attach("FastlyPlatformHttpClient::send_async is not yet implemented")) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported) + .attach("FastlyPlatformHttpClient::select is not yet implemented")) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformGeo +// --------------------------------------------------------------------------- + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// +/// Uses [`geo_from_fastly`] from `trusted_server_core::geo` to avoid +/// duplicating the field-mapping logic present in `GeoInfo::from_request`. +pub struct FastlyPlatformGeo; + +impl PlatformGeo for FastlyPlatformGeo { + fn lookup(&self, client_ip: Option) -> Result, Report> { + Ok(client_ip + .and_then(geo_lookup) + .map(|geo| geo_from_fastly(&geo))) + } +} + +// --------------------------------------------------------------------------- +// Entry-point helper +// --------------------------------------------------------------------------- + +/// Construct a [`RuntimeServices`] instance from the incoming Fastly request. +/// +/// Call this once at the entry point before dispatching to handlers. +/// `client_info` is populated from TLS and IP metadata available on the +/// request; geo lookup is deferred to handler time via +/// `services.geo.lookup(services.client_info.client_ip)`. +/// +/// `kv_store` is an [`Arc`] opened by the caller for +/// the primary KV store. Use [`open_kv_store`] to construct it. +#[must_use] +pub fn build_runtime_services( + req: &Request, + kv_store: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(FastlyPlatformConfigStore)) + .secret_store(Arc::new(FastlyPlatformSecretStore)) + .kv_store(kv_store) + .backend(Arc::new(FastlyPlatformBackend)) + .http_client(Arc::new(FastlyPlatformHttpClient)) + .geo(Arc::new(FastlyPlatformGeo)) + .client_info(ClientInfo { + client_ip: req.get_client_ip_addr(), + tls_protocol: req.get_tls_protocol().map(str::to_string), + tls_cipher: req.get_tls_cipher_openssl_name().map(str::to_string), + }) + .build() +} + +/// Open a named KV store as a [`PlatformKvStore`] implementation. +/// +/// # Errors +/// +/// Returns [`KvError::Unavailable`] when the store does not exist, or +/// [`KvError::Internal`] when the Fastly SDK fails to open it. +pub fn open_kv_store(store_name: &str) -> Result, KvError> { + FastlyKvStore::open(store_name).map(|store| Arc::new(store) as Arc) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use edgezero_core::key_value_store::NoopKvStore; + + use super::*; + + fn noop_kv_store() -> Arc { + Arc::new(NoopKvStore) + } + + // --- FastlyPlatformBackend::predict_name -------------------------------- + + #[test] + fn predict_name_produces_same_name_as_backend_config() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "origin.example.com".to_string(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_secs(15), + }; + + let name = backend + .predict_name(&spec) + .expect("should compute backend name for valid spec"); + + assert_eq!( + name, "backend_https_origin_example_com_443_t15000", + "should match BackendConfig naming convention" + ); + } + + #[test] + fn predict_name_includes_nocert_suffix_when_cert_check_disabled() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "origin.example.com".to_string(), + port: None, + certificate_check: false, + first_byte_timeout: Duration::from_secs(15), + }; + + let name = backend + .predict_name(&spec) + .expect("should compute name with cert check disabled"); + + assert!( + name.contains("nocert"), + "should include nocert suffix when certificate_check is false" + ); + } + + #[test] + fn predict_name_returns_error_for_empty_host() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: String::new(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_secs(15), + }; + + let result = backend.predict_name(&spec); + + assert!(result.is_err(), "should return an error for empty host"); + } + + #[test] + fn predict_name_encodes_custom_timeout() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "origin.example.com".to_string(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(2000), + }; + + let name = backend + .predict_name(&spec) + .expect("should compute name with custom timeout"); + + assert!( + name.ends_with("_t2000"), + "should encode 2000ms timeout in name" + ); + } + + // --- ClientInfo extraction ---------------------------------------------- + + #[test] + fn build_runtime_services_client_info_is_none_without_tls() { + let req = Request::get("https://example.com/"); + let services = build_runtime_services(&req, noop_kv_store()); + + assert!( + services.client_info.tls_protocol.is_none(), + "should have no tls_protocol on plain test request" + ); + assert!( + services.client_info.tls_cipher.is_none(), + "should have no tls_cipher on plain test request" + ); + } + + #[test] + fn build_runtime_services_returns_cloneable_services() { + let req = Request::get("https://example.com/"); + let services = build_runtime_services(&req, noop_kv_store()); + let cloned = services.clone(); + + assert_eq!( + services.client_info.client_ip, cloned.client_info.client_ip, + "should preserve client_ip through clone" + ); + } +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 6a321fd2..25f2b43f 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -72,6 +72,7 @@ default = [] [dev-dependencies] criterion = { workspace = true } +edgezero-core = { workspace = true, features = ["test-utils"] } temp-env = { workspace = true } tokio-test = { workspace = true } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index bf039358..0f9e6550 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -55,6 +55,7 @@ pub async fn handle_auction( // Extract consent from request cookies, headers, and geo. let cookie_jar = handle_request_cookies(&req)?; + #[allow(deprecated)] let geo = GeoInfo::from_request(&req); let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput { jar: cookie_jar.as_ref(), diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index c981e173..0be80d2d 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -138,6 +138,7 @@ pub fn convert_tsjs_to_auction_request( .get_header_str("user-agent") .map(std::string::ToString::to_string), ip: req.get_client_ip_addr().map(|ip| ip.to_string()), + #[allow(deprecated)] geo: GeoInfo::from_request(req), }); diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-core/src/backend.rs index 34e6b523..3df84a7a 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-core/src/backend.rs @@ -90,7 +90,8 @@ impl<'a> BackendConfig<'a> { self } - /// Compute the deterministic backend name without registering anything. + /// Compute the deterministic backend name and resolved port without + /// registering anything. /// /// The name encodes scheme, host, port, certificate setting, and /// first-byte timeout so that backends with different configurations @@ -103,6 +104,16 @@ impl<'a> BackendConfig<'a> { message: "missing host".to_string(), })); } + if self.host.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Proxy { + message: "host contains control characters".to_string(), + })); + } + if self.scheme.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Proxy { + message: "scheme contains control characters".to_string(), + })); + } let target_port = self .port @@ -125,6 +136,19 @@ impl<'a> BackendConfig<'a> { Ok((backend_name, target_port)) } + /// Return the deterministic backend name without registering anything. + /// + /// Convenience wrapper over [`Self::compute_name`] that discards the + /// resolved port, used by [`crate::platform::PlatformBackend`] + /// implementations that only need the name for correlation. + /// + /// # Errors + /// + /// Returns an error if the host is empty. + pub fn predict_name(self) -> Result> { + self.compute_name().map(|(name, _)| name) + } + /// Ensure a dynamic backend exists for this configuration and return its name. /// /// The backend name is derived from the scheme, host, port, certificate @@ -376,6 +400,17 @@ mod tests { assert_eq!(name, "backend_http_example_org_80_t15000"); } + #[test] + fn error_on_host_with_control_characters() { + let err = BackendConfig::new("https", "evil.com\nINFO fake log entry") + .predict_name() + .expect_err("should reject host containing newline"); + assert!( + err.to_string().contains("control characters"), + "should report control characters in error message" + ); + } + #[test] fn error_on_missing_host() { let err = BackendConfig::new("https", "") diff --git a/crates/trusted-server-core/src/fastly_storage.rs b/crates/trusted-server-core/src/fastly_storage.rs index bedd2495..6af3d6b4 100644 --- a/crates/trusted-server-core/src/fastly_storage.rs +++ b/crates/trusted-server-core/src/fastly_storage.rs @@ -9,6 +9,14 @@ use crate::error::TrustedServerError; const FASTLY_API_HOST: &str = "https://api.fastly.com"; +/// Fastly-backed config store with the store name baked in at construction. +/// +/// # Migration note +/// +/// This type predates the `platform` abstraction. New code should use +/// [`crate::platform::PlatformConfigStore`] via [`crate::platform::RuntimeServices`] +/// instead. `FastlyConfigStore` will be removed once all call sites have +/// migrated. pub struct FastlyConfigStore { store_name: String, } @@ -39,6 +47,14 @@ impl FastlyConfigStore { } } +/// Fastly-backed secret store with the store name baked in at construction. +/// +/// # Migration note +/// +/// This type predates the `platform` abstraction. New code should use +/// [`crate::platform::PlatformSecretStore`] via [`crate::platform::RuntimeServices`] +/// instead. `FastlySecretStore` will be removed once all call sites have +/// migrated. pub struct FastlySecretStore { store_name: String, } diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index a554dc3b..3e1e13bc 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -1,48 +1,48 @@ //! Geographic location utilities for the trusted server. //! -//! This module provides functions for extracting and handling geographic -//! information from incoming requests, particularly DMA (Designated Market Area) codes. +//! This module provides Fastly-specific helpers for extracting geographic +//! information from incoming requests and writing geo headers to responses. +//! +//! The [`GeoInfo`] data type is defined in [`crate::platform`] as platform- +//! neutral data; this module re-exports it and adds Fastly-coupled `impl` +//! blocks for construction and response header injection. -use fastly::geo::geo_lookup; +use fastly::geo::{geo_lookup, Geo}; use fastly::{Request, Response}; +pub use crate::platform::GeoInfo; + use crate::constants::{ HEADER_X_GEO_CITY, HEADER_X_GEO_CONTINENT, HEADER_X_GEO_COORDINATES, HEADER_X_GEO_COUNTRY, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, HEADER_X_GEO_REGION, }; -/// Geographic information extracted from a request. +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. /// -/// Contains all available geographic data from Fastly's geolocation service, -/// including city, country, continent, coordinates, and DMA/metro code. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GeoInfo { - /// City name - pub city: String, - /// Two-letter country code (e.g., "US", "GB") - pub country: String, - /// Continent name - pub continent: String, - /// Latitude coordinate - pub latitude: f64, - /// Longitude coordinate - pub longitude: f64, - /// DMA (Designated Market Area) / metro code - pub metro_code: i64, - /// Region code - pub region: Option, +/// Shared by `GeoInfo::from_request` (legacy) and `FastlyPlatformGeo::lookup` in +/// `trusted-server-adapter-fastly` so that field mapping is never duplicated. +pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + } } impl GeoInfo { /// Creates a new `GeoInfo` from a request by performing a geo lookup. /// - /// This constructor performs a geo lookup based on the client's IP address and returns - /// all available geographic data in a structured format. It does not modify the request - /// or set headers. - /// - /// # Arguments + /// # Legacy /// - /// * `req` - The request to extract geographic information from + /// This is a Fastly-coupled convenience method that predates the + /// `platform` abstraction. New code should use + /// `RuntimeServices::geo.lookup(client_info.client_ip)` instead, which + /// goes through [`crate::platform::PlatformGeo`] and does not require + /// direct access to the Fastly request. /// /// # Returns /// @@ -53,33 +53,13 @@ impl GeoInfo { /// ```ignore /// if let Some(geo_info) = GeoInfo::from_request(&req) { /// println!("User is in {} ({})", geo_info.city, geo_info.country); - /// println!("Coordinates: {}", geo_info.coordinates_string()); /// } /// ``` + #[deprecated(note = "Use RuntimeServices::geo().lookup() instead")] pub fn from_request(req: &Request) -> Option { req.get_client_ip_addr() .and_then(geo_lookup) - .map(|geo| GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - }) - } - - /// Returns coordinates as a formatted string "latitude,longitude" - #[must_use] - pub fn coordinates_string(&self) -> String { - format!("{},{}", self.latitude, self.longitude) - } - - /// Checks if a valid metro code is available (non-zero) - #[must_use] - pub fn has_metro_code(&self) -> bool { - self.metro_code > 0 + .map(|geo| geo_from_fastly(&geo)) } /// Sets geo information headers on the response. @@ -291,7 +271,6 @@ mod tests { response.get_header(HEADER_X_GEO_REGION).is_none(), "should not set region header when region is None" ); - // Other headers should still be present assert!( response.get_header(HEADER_X_GEO_CITY).is_some(), "should still set city header" diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 1fd95369..bc986beb 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -50,6 +50,7 @@ pub mod http_util; pub mod integrations; pub mod models; pub mod openrtb; +pub mod platform; pub mod proxy; pub mod publisher; pub mod redacted; diff --git a/crates/trusted-server-core/src/platform/error.rs b/crates/trusted-server-core/src/platform/error.rs new file mode 100644 index 00000000..699eb7d4 --- /dev/null +++ b/crates/trusted-server-core/src/platform/error.rs @@ -0,0 +1,31 @@ +use derive_more::Display; + +/// Root error type for platform service operations. +/// +/// Use with `error-stack`'s `Report` to attach context before propagating. +#[derive(Debug, Display)] +pub enum PlatformError { + /// Input validation failed before delegating to the platform. + #[display("validation error")] + Validation, + /// Config store access failed. + #[display("config store error")] + ConfigStore, + /// Secret store access failed. + #[display("secret store error")] + SecretStore, + /// Backend registration or name computation failed. + #[display("backend error")] + Backend, + /// HTTP client request failed. + #[display("http client error")] + HttpClient, + /// Geo lookup failed. + #[display("geo lookup error")] + Geo, + /// Operation is not supported by this platform adapter. + #[display("unsupported platform operation")] + Unsupported, +} + +impl core::error::Error for PlatformError {} diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs new file mode 100644 index 00000000..757d4a85 --- /dev/null +++ b/crates/trusted-server-core/src/platform/http.rs @@ -0,0 +1,191 @@ +use std::any::Any; +use std::fmt; + +use edgezero_core::http::{Request as EdgeRequest, Response as EdgeResponse}; +use error_stack::Report; + +use super::PlatformError; + +/// Outbound HTTP request paired with a pre-resolved backend name. +/// +/// Uses `EdgeZero`'s neutral [`EdgeRequest`] type so adapters share one +/// HTTP/body model while still preserving backend correlation for Fastly-style +/// fan-out. +#[derive(Debug)] +pub struct PlatformHttpRequest { + /// Platform-neutral request to send upstream. + pub request: EdgeRequest, + /// Backend name resolved ahead of time via `PlatformBackend`. + pub backend_name: String, +} + +impl PlatformHttpRequest { + /// Create a new outbound request wrapper. + #[must_use] + pub fn new(request: EdgeRequest, backend_name: impl Into) -> Self { + Self { + request, + backend_name: backend_name.into(), + } + } +} + +/// Outbound HTTP response with optional backend correlation metadata. +#[derive(Debug)] +pub struct PlatformResponse { + /// Platform-neutral HTTP response. + pub response: EdgeResponse, + /// Backend that produced the response, when known. + pub backend_name: Option, +} + +impl PlatformResponse { + /// Create a response wrapper without backend metadata. + #[must_use] + pub fn new(response: EdgeResponse) -> Self { + Self { + response, + backend_name: None, + } + } + + /// Attach backend correlation metadata to the response. + #[must_use] + pub fn with_backend_name(mut self, backend_name: impl Into) -> Self { + self.backend_name = Some(backend_name.into()); + self + } +} + +/// Opaque handle for an in-flight outbound request. +/// +/// The core stores this as an opaque support type. Adapter implementations can +/// recover their concrete runtime handle through [`Self::downcast`]. +/// +/// # `!Send` design +/// +/// `inner` is `Box` (not `Box`) because all async +/// operations in this platform layer use `#[async_trait(?Send)]`. The `?Send` +/// bound exists because [`edgezero_core::body::Body`] wraps a +/// `LocalBoxStream` that is intentionally `!Send` for wasm32 compatibility — +/// wasm32 targets are single-threaded and cannot use `Send` futures. +/// Adapter crates targeting a multi-threaded runtime (e.g. Axum with tokio) +/// would need to wrap state in `Arc` rather than relying on `Send` here. +pub struct PlatformPendingRequest { + inner: Box, + backend_name: Option, +} + +impl PlatformPendingRequest { + /// Wrap an adapter-specific pending request handle. + #[must_use] + pub fn new(inner: T) -> Self + where + T: Any, + { + Self { + inner: Box::new(inner), + backend_name: None, + } + } + + /// Attach backend correlation metadata to the pending request. + #[must_use] + pub fn with_backend_name(mut self, backend_name: impl Into) -> Self { + self.backend_name = Some(backend_name.into()); + self + } + + /// Return the correlated backend name when it is known before completion. + #[must_use] + pub fn backend_name(&self) -> Option<&str> { + self.backend_name.as_deref() + } + + /// Recover the adapter-specific pending request type. + /// + /// # Errors + /// + /// Returns `Err(self)` — the original wrapper with its backend metadata + /// preserved — when `T` does not match the stored type. + pub fn downcast(self) -> Result + where + T: Any, + { + let Self { + inner, + backend_name, + } = self; + + match inner.downcast::() { + Ok(inner) => Ok(*inner), + Err(inner) => Err(Self { + inner, + backend_name, + }), + } + } +} + +impl fmt::Debug for PlatformPendingRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PlatformPendingRequest") + .field("backend_name", &self.backend_name) + .finish() + } +} + +/// Result of waiting for one in-flight request to complete. +#[derive(Debug)] +pub struct PlatformSelectResult { + /// Completed response, or the error returned by the ready request. + pub ready: Result>, + /// Requests still in flight after the ready result is removed. + pub remaining: Vec, +} + +/// Outbound HTTP client abstraction. +/// +/// This extends `EdgeZero`'s current `ProxyClient` shape so the core platform +/// surface can support both single-request sends and Fastly-style async +/// fan-out (`send_async` + `select`) without keeping parallel abstractions +/// alive. Object safety is provided by `async_trait`, which boxes the returned +/// futures behind `dyn PlatformHttpClient`. +/// +/// Uses `?Send` on all targets because [`edgezero_core::body::Body`] contains +/// a `LocalBoxStream` that is `!Send` by design for wasm32 compatibility. +#[async_trait::async_trait(?Send)] +pub trait PlatformHttpClient: Send + Sync { + /// Send a single upstream request and wait for the response. + /// + /// # Errors + /// + /// Returns `PlatformError::HttpClient` when the request cannot be sent or + /// the platform client fails before a response is produced. + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result>; + + /// Start an upstream request without waiting for it to complete. + /// + /// # Errors + /// + /// Returns `PlatformError::HttpClient` when the request cannot be + /// started. + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result>; + + /// Wait for one of the in-flight requests to complete. + /// + /// # Errors + /// + /// Returns `PlatformError::HttpClient` if the platform cannot poll the + /// pending requests at all. + async fn select( + &self, + pending_requests: Vec, + ) -> Result>; +} diff --git a/crates/trusted-server-core/src/platform/kv.rs b/crates/trusted-server-core/src/platform/kv.rs new file mode 100644 index 00000000..81d5a82d --- /dev/null +++ b/crates/trusted-server-core/src/platform/kv.rs @@ -0,0 +1,47 @@ +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore as PlatformKvStore}; + +/// A [`PlatformKvStore`] stand-in used when the primary KV store cannot be +/// opened at startup. +/// +/// Every method returns [`KvError::Unavailable`], ensuring that handlers +/// which call [`crate::platform::RuntimeServices::kv_handle`] receive a typed +/// error rather than a panic. Routes that do not touch the KV store are +/// unaffected. +/// +/// Adapter crates should use this type rather than defining their own stub so +/// the fallback behaviour is consistent across all platform implementations. +pub struct UnavailableKvStore; + +#[async_trait::async_trait(?Send)] +impl PlatformKvStore for UnavailableKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Err(KvError::Unavailable) + } + + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Err(KvError::Unavailable) + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs new file mode 100644 index 00000000..65b843f8 --- /dev/null +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -0,0 +1,272 @@ +//! Platform abstraction layer for `trusted-server-core`. +//! +//! This module defines platform-neutral service contracts and request-scoped +//! runtime state. Concrete implementations live in adapter crates such as +//! `trusted-server-adapter-fastly`. +//! +//! ## Traits +//! +//! - [`PlatformConfigStore`] — key-value config store access +//! - [`PlatformSecretStore`] — encrypted secret store access +//! - [`PlatformKvStore`] — (re-exported from `edgezero_core`) +//! - [`PlatformBackend`] — dynamic backend registration +//! - [`PlatformHttpClient`] — outbound HTTP client +//! - [`PlatformGeo`] — geographic information lookup + +mod error; +mod http; +mod kv; +mod traits; +mod types; + +pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; +pub use error::PlatformError; +pub use http::{ + PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + PlatformSelectResult, +}; +pub use kv::UnavailableKvStore; +pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; +pub use types::{ + ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, + StoreName, +}; + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + use std::sync::Arc; + + use error_stack::Report; + + use super::*; + + fn _assert_config_store_object_safe(_: &dyn PlatformConfigStore) {} + fn _assert_secret_store_object_safe(_: &dyn PlatformSecretStore) {} + fn _assert_kv_store_object_safe(_: &dyn PlatformKvStore) {} + fn _assert_backend_object_safe(_: &dyn PlatformBackend) {} + fn _assert_http_client_object_safe(_: &dyn PlatformHttpClient) {} + fn _assert_geo_object_safe(_: &dyn PlatformGeo) {} + // Arc requires the trait impl to be Send + Sync. The assertion + // below documents that RuntimeServices itself satisfies those bounds — the + // compiler verifies this at the point where RuntimeServices is constructed. + fn _assert_runtime_services_send_sync() + where + RuntimeServices: Send + Sync, + { + } + + struct NoopConfigStore; + impl PlatformConfigStore for NoopConfigStore { + fn get( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct NoopSecretStore; + impl PlatformSecretStore for NoopSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct NoopBackend; + impl PlatformBackend for NoopBackend { + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct NoopHttpClient; + // ?Send matches PlatformHttpClient — Body wraps LocalBoxStream which is !Send + // by design; see http.rs for the full rationale. + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for NoopHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + 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)) + } + } + + struct NoopGeo; + impl PlatformGeo for NoopGeo { + fn lookup( + &self, + _client_ip: Option, + ) -> Result, Report> { + Ok(None) + } + } + + fn noop_services() -> RuntimeServices { + // edgezero_core::key_value_store::NoopKvStore is available via the + // test-utils feature enabled in dev-dependencies. + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }) + .build() + } + + #[test] + fn runtime_services_can_be_constructed_and_cloned() { + let services = noop_services(); + let cloned = services.clone(); + + assert!( + cloned.client_info.client_ip.is_none(), + "should preserve client_ip through clone" + ); + assert!( + cloned.client_info.tls_protocol.is_none(), + "should preserve tls_protocol through clone" + ); + } + + #[test] + fn runtime_services_geo_lookup_returns_none_for_no_ip() { + let services = noop_services(); + let result = services + .geo + .lookup(services.client_info.client_ip) + .expect("should not fail for noop geo with no ip"); + assert!(result.is_none(), "should return None when no IP is present"); + } + + #[test] + fn platform_pending_request_downcasts_and_preserves_backend_name() { + let pending = PlatformPendingRequest::new(7_u8).with_backend_name("origin"); + let pending = pending + .downcast::() + .expect_err("should reject downcast to the wrong pending type"); + assert_eq!( + pending.backend_name(), + Some("origin"), + "should preserve backend metadata when downcast fails" + ); + + let pending = PlatformPendingRequest::new(7_u8).with_backend_name("origin"); + let value = pending + .downcast::() + .expect("should recover the stored pending request type"); + assert_eq!(value, 7, "should preserve the stored pending request"); + } + + #[test] + fn geo_info_coordinates_string_formats_correctly() { + let geo = GeoInfo { + city: "New York".to_string(), + country: "US".to_string(), + continent: "NorthAmerica".to_string(), + latitude: 40.7128, + longitude: -74.0060, + metro_code: 501, + region: Some("NY".to_string()), + }; + + assert_eq!( + geo.coordinates_string(), + "40.7128,-74.006", + "should format coordinates as lat,lon" + ); + } + + #[test] + fn geo_info_has_metro_code_returns_true_for_nonzero() { + let geo = GeoInfo { + city: String::new(), + country: String::new(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 807, + region: None, + }; + assert!( + geo.has_metro_code(), + "should return true for non-zero metro code" + ); + } + + #[test] + fn geo_info_has_metro_code_returns_false_for_zero() { + let geo = GeoInfo { + city: String::new(), + country: String::new(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + }; + assert!( + !geo.has_metro_code(), + "should return false for zero metro code" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs new file mode 100644 index 00000000..ecc886c9 --- /dev/null +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -0,0 +1,124 @@ +use std::net::IpAddr; + +use error_stack::Report; + +use super::{GeoInfo, PlatformBackendSpec, PlatformError, StoreId, StoreName}; + +/// Synchronous, object-safe access to a key-value config store. +/// +/// Reads use the edge-visible store name. Writes use the platform management +/// store identifier because Fastly separates the runtime store name from the +/// management API store ID. +pub trait PlatformConfigStore: Send + Sync { + /// Retrieve a string value from `store_name` by `key`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the key does not exist or + /// the store cannot be opened. + fn get(&self, store_name: &StoreName, key: &str) -> Result>; + + /// Store a string value in the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the write fails or the + /// platform management API is unreachable. + fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report>; + + /// Delete a key from the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the delete fails or the + /// platform management API is unreachable. + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report>; +} + +/// Synchronous, object-safe access to a secret store. +/// +/// Reads use the edge-visible store name. Writes use the platform management +/// store identifier. +pub trait PlatformSecretStore: Send + Sync { + /// Retrieve a secret value as raw bytes from `store_name` by `key`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the store cannot be opened, + /// the key does not exist, or decryption fails. + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report>; + + /// Retrieve a secret value as a UTF-8 string from `store_name` by `key`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the secret cannot be + /// retrieved or is not valid UTF-8. + fn get_string( + &self, + store_name: &StoreName, + key: &str, + ) -> Result> { + let bytes = self.get_bytes(store_name, key)?; + String::from_utf8(bytes).map_err(|error| { + Report::new(PlatformError::SecretStore) + .attach(format!("secret is not valid UTF-8: {error}")) + }) + } + + /// Create or overwrite a secret in the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the create fails or the + /// platform management API is unreachable. + fn create( + &self, + store_id: &StoreId, + name: &str, + value: &str, + ) -> Result<(), Report>; + + /// Delete a secret from the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the delete fails or the + /// platform management API is unreachable. + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report>; +} + +/// Synchronous, object-safe dynamic backend management. +pub trait PlatformBackend: Send + Sync { + /// Compute the deterministic backend name for the given spec without + /// registering anything. + /// + /// # Errors + /// + /// Returns [`PlatformError::Backend`] when the spec is invalid or the + /// name cannot be computed. + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result>; + + /// Ensure a dynamic backend exists for the given spec and return its name. + /// + /// # Errors + /// + /// Returns [`PlatformError::Backend`] when the backend cannot be + /// registered on the platform. + fn ensure(&self, spec: &PlatformBackendSpec) -> Result>; +} + +/// Synchronous, object-safe geo lookup. +pub trait PlatformGeo: Send + Sync { + /// Look up geographic information for the given client IP address. + /// + /// # Errors + /// + /// Returns [`PlatformError::Geo`] when the platform geo lookup fails + /// unexpectedly. Returns `Ok(None)` when no data is available for the IP. + fn lookup(&self, client_ip: Option) -> Result, Report>; +} diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs new file mode 100644 index 00000000..05b1c1b8 --- /dev/null +++ b/crates/trusted-server-core/src/platform/types.rs @@ -0,0 +1,330 @@ +use std::fmt; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +use super::{ + PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, + PlatformSecretStore, +}; + +/// Geographic information extracted from a request. +/// +/// Serde derives are required because `GeoInfo` is embedded in +/// `AuctionRequest`, which is serialised for bid-request payloads. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GeoInfo { + /// City name. + pub city: String, + /// Two-letter country code. + pub country: String, + /// Continent name. + pub continent: String, + /// Latitude coordinate. + pub latitude: f64, + /// Longitude coordinate. + pub longitude: f64, + /// DMA (Designated Market Area) / metro code. + pub metro_code: i64, + /// Region code. + pub region: Option, +} + +impl GeoInfo { + /// Returns coordinates as a formatted string `"latitude,longitude"`. + #[must_use] + pub fn coordinates_string(&self) -> String { + format!("{},{}", self.latitude, self.longitude) + } + + /// Checks if a valid metro code is available. + #[must_use] + pub fn has_metro_code(&self) -> bool { + self.metro_code > 0 + } +} + +/// Per-request client metadata extracted once at the adapter entry point. +#[derive(Debug, Clone)] +pub struct ClientInfo { + /// Client IP address, if available. + pub client_ip: Option, + /// TLS protocol version string, if the connection used TLS. + pub tls_protocol: Option, + /// OpenSSL cipher name, if the connection used TLS. + pub tls_cipher: Option, +} + +/// Edge-visible name used to open a config or secret store at runtime. +/// +/// Passed to read methods on [`super::PlatformConfigStore`] and +/// [`super::PlatformSecretStore`]. Distinct from [`StoreId`] to prevent +/// accidentally passing a management API identifier where a runtime name is +/// expected. +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub struct StoreName(String); + +impl From for StoreName { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for StoreName { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl AsRef for StoreName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Management API identifier used to write to a config or secret store. +/// +/// Passed to write methods on [`super::PlatformConfigStore`] and +/// [`super::PlatformSecretStore`]. Distinct from [`StoreName`] to prevent +/// accidentally passing a runtime store name where a management API +/// identifier is expected. +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub struct StoreId(String); + +impl From for StoreId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for StoreId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl AsRef for StoreId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Input specification for a dynamic backend. +/// +/// Passed to [`PlatformBackend::predict_name`] and [`PlatformBackend::ensure`] +/// to deterministically name and register upstream origins. +#[derive(Debug, Clone)] +pub struct PlatformBackendSpec { + /// URL scheme. + pub scheme: String, + /// Hostname of the backend origin. + pub host: String, + /// Explicit port, or `None` to use the scheme default. + pub port: Option, + /// Whether to verify the TLS certificate. + pub certificate_check: bool, + /// Maximum time to wait for the first response byte. + pub first_byte_timeout: Duration, +} + +/// Cloneable container of platform services for a single request. +#[derive(Clone)] +pub struct RuntimeServices { + /// Access to key-value config stores. + pub(crate) config_store: Arc, + /// Access to encrypted secret stores. + pub(crate) secret_store: Arc, + /// KV store for the primary (opid) store. + /// + /// Additional stores (`counter_store`, `creative_store`) are opened on + /// demand in individual handlers until multi-store support lands here. + pub(crate) kv_store: Arc, + /// Dynamic backend registration and name prediction. + pub(crate) backend: Arc, + /// Outbound HTTP client abstraction. + pub(crate) http_client: Arc, + /// Geographic information lookup. + pub(crate) geo: Arc, + /// Per-request client metadata extracted at the entry point. + pub client_info: ClientInfo, +} + +impl RuntimeServices { + /// Create a builder for [`RuntimeServices`]. + /// + /// Adapter crates should use this builder rather than constructing + /// [`RuntimeServices`] directly, so that any future invariants on the + /// struct are enforced in one place. + /// + /// # Examples + /// + /// ```ignore + /// let services = RuntimeServices::builder() + /// .config_store(Arc::new(MyConfigStore)) + /// .secret_store(Arc::new(MySecretStore)) + /// .kv_store(kv_store) + /// .backend(Arc::new(MyBackend)) + /// .http_client(Arc::new(MyHttpClient)) + /// .geo(Arc::new(MyGeo)) + /// .client_info(client_info) + /// .build(); + /// ``` + #[must_use] + pub fn builder() -> RuntimeServicesBuilder { + RuntimeServicesBuilder::new() + } + + /// Returns the config store service. + #[must_use] + pub fn config_store(&self) -> &dyn PlatformConfigStore { + &*self.config_store + } + + /// Returns the secret store service. + #[must_use] + pub fn secret_store(&self) -> &dyn PlatformSecretStore { + &*self.secret_store + } + + /// Returns the dynamic backend service. + #[must_use] + pub fn backend(&self) -> &dyn PlatformBackend { + &*self.backend + } + + /// Returns the outbound HTTP client service. + #[must_use] + pub fn http_client(&self) -> &dyn PlatformHttpClient { + &*self.http_client + } + + /// Returns the platform geo lookup service. + #[must_use] + pub fn geo(&self) -> &dyn PlatformGeo { + &*self.geo + } + + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to + /// JSON helpers, pagination, and validation. + #[must_use] + pub fn kv_handle(&self) -> super::KvHandle { + super::KvHandle::new(self.kv_store.clone()) + } +} + +impl fmt::Debug for RuntimeServices { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RuntimeServices") + .field("client_info", &self.client_info) + .finish_non_exhaustive() + } +} + +/// Builder for [`RuntimeServices`]. +/// +/// Obtain a builder via [`RuntimeServices::builder`] and set each service +/// before calling [`RuntimeServicesBuilder::build`]. +pub struct RuntimeServicesBuilder { + config_store: Option>, + secret_store: Option>, + kv_store: Option>, + backend: Option>, + http_client: Option>, + geo: Option>, + client_info: Option, +} + +impl RuntimeServicesBuilder { + fn new() -> Self { + Self { + config_store: None, + secret_store: None, + kv_store: None, + backend: None, + http_client: None, + geo: None, + client_info: None, + } + } + + /// Set the config store implementation. + #[must_use] + pub fn config_store(mut self, config_store: Arc) -> Self { + self.config_store = Some(config_store); + self + } + + /// Set the secret store implementation. + #[must_use] + pub fn secret_store(mut self, secret_store: Arc) -> Self { + self.secret_store = Some(secret_store); + self + } + + /// Set the KV store implementation. + #[must_use] + pub fn kv_store(mut self, kv_store: Arc) -> Self { + self.kv_store = Some(kv_store); + self + } + + /// Set the backend implementation. + #[must_use] + pub fn backend(mut self, backend: Arc) -> Self { + self.backend = Some(backend); + self + } + + /// Set the HTTP client implementation. + #[must_use] + pub fn http_client(mut self, http_client: Arc) -> Self { + self.http_client = Some(http_client); + self + } + + /// Set the geo lookup implementation. + #[must_use] + pub fn geo(mut self, geo: Arc) -> Self { + self.geo = Some(geo); + self + } + + /// Set the per-request client metadata. + #[must_use] + pub fn client_info(mut self, client_info: ClientInfo) -> Self { + self.client_info = Some(client_info); + self + } + + /// Construct [`RuntimeServices`] from the accumulated configuration. + /// + /// # Panics + /// + /// Panics if any required service has not been set via the builder methods. + #[must_use] + pub fn build(self) -> RuntimeServices { + RuntimeServices { + config_store: self + .config_store + .expect("should set config_store before building RuntimeServices"), + secret_store: self + .secret_store + .expect("should set secret_store before building RuntimeServices"), + kv_store: self + .kv_store + .expect("should set kv_store before building RuntimeServices"), + backend: self + .backend + .expect("should set backend before building RuntimeServices"), + http_client: self + .http_client + .expect("should set http_client before building RuntimeServices"), + geo: self + .geo + .expect("should set geo before building RuntimeServices"), + client_info: self + .client_info + .expect("should set client_info before building RuntimeServices"), + } + } +} diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a2f54441..9ce4a8c6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -256,6 +256,7 @@ pub fn handle_publisher_request( // (for OpenRTB forwarding) and decoded data (for enforcement). // When a consent_store is configured, this also persists consent to KV // and falls back to stored consent when cookies are absent. + #[allow(deprecated)] let geo = crate::geo::GeoInfo::from_request(&req); let consent_context = build_consent_context(&ConsentPipelineInput { jar: cookie_jar.as_ref(),