Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ use trusted_server_core::settings_data::get_settings;

mod error;
mod platform;
#[cfg(test)]
mod route_tests;

use crate::error::to_error_response;
use crate::platform::{build_runtime_services, UnavailableKvStore};
use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore};

#[fastly::main]
fn main(req: Request) -> Result<Response, Error> {
Expand Down Expand Up @@ -69,9 +71,9 @@ fn main(req: Request) -> Result<Response, Error> {
}
};

// No KV store is currently required — Edge Cookie generation no longer
// uses one. When a feature needs a KV store, add a config field and call
// `open_kv_store` here.
// Start with an unavailable KV slot. Consent-dependent routes lazily
// replace it with the configured store at dispatch time so unrelated
// routes stay available when consent persistence is misconfigured.
let kv_store = std::sync::Arc::new(UnavailableKvStore)
as std::sync::Arc<dyn trusted_server_core::platform::PlatformKvStore>;
let runtime_services = build_runtime_services(&req, kv_store);
Expand Down Expand Up @@ -149,7 +151,14 @@ async fn route_request(
(Method::POST, "/admin/keys/deactivate") => handle_deactivate_key(settings, req),

// Unified auction endpoint (returns creative HTML inline)
(Method::POST, "/auction") => handle_auction(settings, orchestrator, req).await,
(Method::POST, "/auction") => {
match runtime_services_for_consent_route(settings, runtime_services) {
Ok(auction_services) => {
handle_auction(settings, orchestrator, &auction_services, req).await
}
Err(e) => Err(e),
}
}

// tsjs endpoints
(Method::GET, "/first-party/proxy") => handle_first_party_proxy(settings, req).await,
Expand All @@ -176,12 +185,22 @@ async fn route_request(
path
);

match handle_publisher_request(settings, integration_registry, req) {
Ok(response) => Ok(response),
Err(e) => {
log::error!("Failed to proxy to publisher origin: {:?}", e);
Err(e)
match runtime_services_for_consent_route(settings, runtime_services) {
Ok(publisher_services) => {
match handle_publisher_request(
settings,
integration_registry,
&publisher_services,
req,
) {
Ok(response) => Ok(response),
Err(e) => {
log::error!("Failed to proxy to publisher origin: {:?}", e);
Err(e)
}
}
}
Err(e) => Err(e),
}
}
};
Expand All @@ -194,6 +213,24 @@ async fn route_request(
Ok(response)
}

fn runtime_services_for_consent_route(
settings: &Settings,
runtime_services: &RuntimeServices,
) -> Result<RuntimeServices, Report<TrustedServerError>> {
let Some(store_name) = settings.consent.consent_store.as_deref() else {
return Ok(runtime_services.clone());
};

open_kv_store(store_name)
.map(|store| runtime_services.clone().with_kv_store(store))
.map_err(|e| {
Report::new(TrustedServerError::KvStore {
store_name: store_name.to_string(),
message: e.to_string(),
})
})
}

/// Applies all standard response headers: geo, version, staging, and configured headers.
///
/// Called from every response path (including auth early-returns) so that all
Expand Down
1 change: 0 additions & 1 deletion crates/trusted-server-adapter-fastly/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,6 @@ pub fn build_runtime_services(
///
/// Returns [`KvError::Unavailable`] when the store does not exist, or
/// [`KvError::Internal`] when the Fastly SDK fails to open it.
#[allow(dead_code)]
pub fn open_kv_store(store_name: &str) -> Result<Arc<dyn PlatformKvStore>, KvError> {
FastlyKvStore::open(store_name).map(|store| Arc::new(store) as Arc<dyn PlatformKvStore>)
}
Expand Down
251 changes: 251 additions & 0 deletions crates/trusted-server-adapter-fastly/src/route_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
use std::net::IpAddr;
use std::sync::Arc;

use edgezero_core::key_value_store::NoopKvStore;
use error_stack::Report;
use fastly::http::StatusCode;
use fastly::Request;
use trusted_server_core::auction::build_orchestrator;
use trusted_server_core::integrations::IntegrationRegistry;
use trusted_server_core::platform::{
ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError,
PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest,
PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId,
StoreName,
};
use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME;
use trusted_server_core::settings::Settings;

use super::route_request;

struct StubJwksConfigStore;

impl PlatformConfigStore for StubJwksConfigStore {
fn get(&self, _store_name: &StoreName, key: &str) -> Result<String, Report<PlatformError>> {
match key {
"active-kids" => Ok("test-kid-1".to_string()),
"test-kid-1" => Ok(
r#"{"kty":"OKP","crv":"Ed25519","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","kid":"test-kid-1","alg":"EdDSA"}"#
.to_string(),
),
_ => Err(Report::new(PlatformError::ConfigStore)),
}
}

fn put(
&self,
_store_id: &StoreId,
_key: &str,
_value: &str,
) -> Result<(), Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}

fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}
}

struct NoopSecretStore;

impl PlatformSecretStore for NoopSecretStore {
fn get_bytes(
&self,
_store_name: &StoreName,
_key: &str,
) -> Result<Vec<u8>, Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}

fn create(
&self,
_store_id: &StoreId,
_name: &str,
_value: &str,
) -> Result<(), Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}

fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}
}

struct NoopBackend;

impl PlatformBackend for NoopBackend {
fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result<String, Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}

fn ensure(&self, _spec: &PlatformBackendSpec) -> Result<String, Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}
}

struct NoopHttpClient;

#[async_trait::async_trait(?Send)]
impl PlatformHttpClient for NoopHttpClient {
async fn send(
&self,
_request: PlatformHttpRequest,
) -> Result<PlatformResponse, Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}

async fn send_async(
&self,
_request: PlatformHttpRequest,
) -> Result<PlatformPendingRequest, Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}

async fn select(
&self,
_pending_requests: Vec<PlatformPendingRequest>,
) -> Result<PlatformSelectResult, Report<PlatformError>> {
Err(Report::new(PlatformError::Unsupported))
}
}

struct NoopGeo;

impl PlatformGeo for NoopGeo {
fn lookup(&self, _client_ip: Option<IpAddr>) -> Result<Option<GeoInfo>, Report<PlatformError>> {
Ok(None)
}
}

fn create_test_settings() -> Settings {
let settings = Settings::from_toml(
r#"
[[handlers]]
path = "^/admin"
username = "admin"
password = "admin-pass"

[publisher]
domain = "test-publisher.com"
cookie_domain = ".test-publisher.com"
origin_url = "https://origin.test-publisher.com"
proxy_secret = "unit-test-proxy-secret"

[edge_cookie]
secret_key = "test-secret-key"

[request_signing]
enabled = false
config_store_id = "test-config-store-id"
secret_store_id = "test-secret-store-id"

[consent]
consent_store = "missing-consent-store"

[integrations.prebid]
enabled = true
server_url = "https://test-prebid.com/openrtb2/auction"

[auction]
enabled = true
providers = ["prebid"]
timeout_ms = 2000
"#,
)
.expect("should parse adapter route test settings");

assert_eq!(
JWKS_CONFIG_STORE_NAME, "jwks_store",
"should keep the stub discovery store aligned with the production constant"
);

settings
}

fn test_runtime_services(req: &Request) -> RuntimeServices {
RuntimeServices::builder()
.config_store(Arc::new(StubJwksConfigStore))
.secret_store(Arc::new(NoopSecretStore))
.kv_store(Arc::new(NoopKvStore) as Arc<dyn PlatformKvStore>)
.backend(Arc::new(NoopBackend))
.http_client(Arc::new(NoopHttpClient))
.geo(Arc::new(NoopGeo))
.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()
}

#[test]
fn configured_missing_consent_store_only_breaks_consent_routes() {
let settings = create_test_settings();
let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator");
let integration_registry =
IntegrationRegistry::new(&settings).expect("should create integration registry");

let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json");
let discovery_services = test_runtime_services(&discovery_req);
let discovery_resp = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&discovery_services,
discovery_req,
))
.expect("should route discovery request");
assert_eq!(
discovery_resp.get_status(),
StatusCode::OK,
"should keep discovery available when the consent store is unavailable"
);

let admin_req = Request::post("https://test.com/admin/keys/rotate");
let admin_services = test_runtime_services(&admin_req);
let admin_resp = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&admin_services,
admin_req,
))
.expect("should route admin request");
assert_eq!(
admin_resp.get_status(),
StatusCode::UNAUTHORIZED,
"should keep admin auth behavior unchanged when the consent store is unavailable"
);

let auction_req = Request::post("https://test.com/auction").with_body(r#"{"adUnits":[]}"#);
let auction_services = test_runtime_services(&auction_req);
let auction_resp = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&auction_services,
auction_req,
))
.expect("should return an error response for auction requests");
assert_eq!(
auction_resp.get_status(),
StatusCode::SERVICE_UNAVAILABLE,
"should fail auction requests when consent persistence is configured but unavailable"
);

let publisher_req = Request::get("https://test.com/articles/example");
let publisher_services = test_runtime_services(&publisher_req);
let publisher_resp = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&publisher_services,
publisher_req,
))
.expect("should return an error response for publisher fallback");
assert_eq!(
publisher_resp.get_status(),
StatusCode::SERVICE_UNAVAILABLE,
"should scope consent store failures to the consent-dependent routes"
);
}
11 changes: 7 additions & 4 deletions crates/trusted-server-core/src/auction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ When a request arrives at the `/auction` endpoint, it goes through the following
┌──────────────────────────────────────────────────────────────────────┐
│ 2. Route Matching (crates/trusted-server-adapter-fastly/src/main.rs:84)
│ 2. Route Matching (crates/trusted-server-adapter-fastly/src/main.rs)
│ - Pattern: (Method::POST, "/auction") │
│ - Handler: handle_auction(settings, &orchestrator, &storage, req)│
│ - Handler: handle_auction(settings, &orchestrator, │
│ &runtime_services, req) │
└──────────────────────────────────────────────────────────────────────┘
Expand Down Expand Up @@ -277,7 +278,9 @@ The Fastly Compute entrypoint uses pattern matching on `(Method, path)` tuples:
```rust
let result = match (method, path.as_str()) {
// Auction endpoint
(Method::POST, "/auction") => handle_auction(&settings, req).await,
(Method::POST, "/auction") => {
handle_auction(&settings, &orchestrator, &runtime_services, req).await
},

// First-party endpoints
(Method::GET, "/first-party/proxy") => handle_first_party_proxy(&settings, req).await,
Expand All @@ -288,7 +291,7 @@ let result = match (method, path.as_str()) {
},

// Fallback to publisher origin
_ => handle_publisher_request(&settings, &integration_registry, req),
_ => handle_publisher_request(&settings, &integration_registry, &runtime_services, req),
}
```

Expand Down
Loading
Loading