From bbdb14018c2303695f5169c0e90cb7296b838355 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 17 Apr 2026 13:58:50 +0530 Subject: [PATCH 1/9] Replace std::time::Instant with web_time::Instant in auction orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wasm32-unknown-unknown (Cloudflare Workers) does not support std::time::Instant — it panics at runtime. web_time::Instant is a zero-cost drop-in on native and JS-backed on WASM. --- Cargo.toml | 1 + crates/trusted-server-core/Cargo.toml | 1 + .../src/auction/orchestrator.rs | 14 +++++++++----- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e8d803a9..eb5b33cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,5 +93,6 @@ url = "2.5.8" urlencoding = "2.1" uuid = { version = "1.18", features = ["v4"] } validator = { version = "0.20", features = ["derive"] } +web-time = "1" which = "8" criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index e91a7ecf..46307478 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -50,6 +50,7 @@ uuid = { workspace = true } validator = { workspace = true } ed25519-dalek = { workspace = true } edgezero-core = { workspace = true } +web-time = { workspace = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] # Enable JS-backed RNG for `wasm32-unknown-unknown` targets (e.g. Cloudflare Workers). diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index e1d27b30..2daff62f 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -3,7 +3,8 @@ use error_stack::{Report, ResultExt}; use std::collections::HashMap; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; +use web_time::Instant; use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, RuntimeServices}; @@ -621,6 +622,9 @@ impl OrchestrationResult { #[cfg(test)] mod tests { + use std::time::Duration; + use web_time::Instant; + use crate::auction::config::AuctionConfig; use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, Bid, MediaType, PublisherInfo, UserInfo, @@ -827,7 +831,7 @@ mod tests { #[test] fn remaining_budget_returns_full_timeout_immediately() { - let start = std::time::Instant::now(); + let start = Instant::now(); let result = super::remaining_budget_ms(start, 2000); // Should be very close to 2000 (allow a few ms for test execution) assert!( @@ -839,7 +843,7 @@ mod tests { #[test] fn remaining_budget_saturates_at_zero() { // Create an instant in the past by sleeping briefly with a tiny timeout - let start = std::time::Instant::now(); + let start = Instant::now(); // Use a timeout of 0 — elapsed will always exceed it let result = super::remaining_budget_ms(start, 0); assert_eq!(result, 0, "should return 0 when timeout is 0"); @@ -847,8 +851,8 @@ mod tests { #[test] fn remaining_budget_decreases_over_time() { - let start = std::time::Instant::now(); - std::thread::sleep(std::time::Duration::from_millis(50)); + let start = Instant::now(); + std::thread::sleep(Duration::from_millis(50)); let result = super::remaining_budget_ms(start, 2000); assert!( result < 2000, From c4066069c5099b8be08f64647824c1d5fa8ea718 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 17 Apr 2026 14:04:33 +0530 Subject: [PATCH 2/9] Add trusted-server-adapter-cloudflare crate with host-target smoke tests Implements the Cloudflare Workers adapter following the same pattern as trusted-server-adapter-axum: TrustedServerApp implements the Hooks trait, platform services use noop stubs on native (CI-compilable), and the #[event(fetch)] entry point delegates to edgezero_adapter_cloudflare::run_app. Also adds UnavailableHttpClient to trusted-server-core platform module, parallel to the existing UnavailableKvStore. --- Cargo.toml | 1 + .../.gitignore | 2 + .../Cargo.toml | 30 ++ .../cloudflare.toml | 21 ++ .../src/app.rs | 338 ++++++++++++++++++ .../src/lib.rs | 15 + .../src/platform.rs | 101 ++++++ .../tests/routes.rs | 21 ++ .../wrangler.toml | 11 + .../trusted-server-core/src/platform/http.rs | 37 ++ .../trusted-server-core/src/platform/mod.rs | 2 +- 11 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 crates/trusted-server-adapter-cloudflare/.gitignore create mode 100644 crates/trusted-server-adapter-cloudflare/Cargo.toml create mode 100644 crates/trusted-server-adapter-cloudflare/cloudflare.toml create mode 100644 crates/trusted-server-adapter-cloudflare/src/app.rs create mode 100644 crates/trusted-server-adapter-cloudflare/src/lib.rs create mode 100644 crates/trusted-server-adapter-cloudflare/src/platform.rs create mode 100644 crates/trusted-server-adapter-cloudflare/tests/routes.rs create mode 100644 crates/trusted-server-adapter-cloudflare/wrangler.toml diff --git a/Cargo.toml b/Cargo.toml index eb5b33cb..04a09115 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/trusted-server-core", "crates/trusted-server-adapter-fastly", "crates/trusted-server-adapter-axum", + "crates/trusted-server-adapter-cloudflare", "crates/js", "crates/openrtb", ] diff --git a/crates/trusted-server-adapter-cloudflare/.gitignore b/crates/trusted-server-adapter-cloudflare/.gitignore new file mode 100644 index 00000000..6fd91c3b --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/.gitignore @@ -0,0 +1,2 @@ +target/ +.edgezero/ diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml new file mode 100644 index 00000000..4eb4dd28 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "trusted-server-adapter-cloudflare" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true + +[lib] +name = "trusted_server_adapter_cloudflare" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] + +[dependencies] +edgezero-adapter-cloudflare = { workspace = true } +edgezero-core = { workspace = true } +error-stack = { workspace = true } +log = { workspace = true } +trusted-server-core = { path = "../trusted-server-core" } +trusted-server-js = { path = "../js" } +worker = { version = "0.6", default-features = false, features = ["http"], optional = true } + +[dev-dependencies] +edgezero-core = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-cloudflare/cloudflare.toml b/crates/trusted-server-adapter-cloudflare/cloudflare.toml new file mode 100644 index 00000000..0e81b15a --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/cloudflare.toml @@ -0,0 +1,21 @@ +[app] +name = "trusted-server" +version = "0.1.0" +kind = "http" + +[adapters.cloudflare] + +[stores.kv] +name = "trusted_server_kv" +[stores.kv.adapters] +cloudflare = "TRUSTED_SERVER_KV" + +[stores.config] +name = "trusted_server_config" +[stores.config.adapters] +cloudflare = "TRUSTED_SERVER_CONFIG" + +[stores.secrets] +name = "trusted_server_secrets" +[stores.secrets.adapters.cloudflare] +enabled = true diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs new file mode 100644 index 00000000..c6b709b3 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -0,0 +1,338 @@ +use std::sync::Arc; + +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderValue, Response, header}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +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, +}; +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::platform::build_runtime_services; + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +/// Application state built once at startup and shared across all requests. +pub struct AppState { + settings: Arc, + orchestrator: Arc, + registry: 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)?; + + Ok(Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + })) +} + +// --------------------------------------------------------------------------- +// Per-request RuntimeServices +// --------------------------------------------------------------------------- + +fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx) +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- + +/// Convert a [`Report`] into an HTTP [`Response`]. +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. +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); + } + }; + + // /.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(&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(&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(&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(&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(&ctx); + let req = ctx.into_request(); + Ok( + handle_auction(&s.settings, &s.orchestrator, &services, None, req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + + // GET /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(&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(&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(&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(&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(&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, 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(&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, None, 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(&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, None, req).await + }; + + Ok(result.unwrap_or_else(|e| http_error(&e))) + } + }; + + RouterService::builder() + .get("/.well-known/trusted-server.json", discovery_handler) + .post("/verify-signature", verify_handler) + .post("/admin/keys/rotate", rotate_handler) + .post("/admin/keys/deactivate", deactivate_handler) + .post("/auction", auction_handler) + .get("/first-party/proxy", fp_proxy_handler) + .get("/first-party/click", fp_click_handler) + .get("/first-party/sign", fp_sign_get_handler) + .post("/first-party/sign", fp_sign_post_handler) + .post("/first-party/proxy-rebuild", fp_rebuild_handler) + .get("/", get_fallback.clone()) + .post("/", post_fallback.clone()) + .get("/{*rest}", get_fallback) + .post("/{*rest}", post_fallback) + .build() + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs new file mode 100644 index 00000000..e9266b09 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -0,0 +1,15 @@ +pub mod app; +pub mod platform; + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::*; + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[event(fetch)] +pub async fn main( + req: Request, + env: Env, + ctx: Context, +) -> Result { + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs new file mode 100644 index 00000000..ce126ae1 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -0,0 +1,101 @@ +use std::net::IpAddr; +use std::sync::Arc; + +use error_stack::Report; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformGeo, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableHttpClient, + UnavailableKvStore, +}; + +// --------------------------------------------------------------------------- +// Noop stubs for host-target builds (native CI, unit tests) +// --------------------------------------------------------------------------- + +struct NoopConfigStore; + +impl PlatformConfigStore for NoopConfigStore { + fn get(&self, _: &StoreName, _: &str) -> Result> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } +} + +struct NoopSecretStore; + +impl PlatformSecretStore for NoopSecretStore { + fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } +} + +struct NoopBackend; + +impl PlatformBackend for NoopBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + Ok(format!("{}_{}", spec.scheme, spec.host)) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } +} + +struct NoopGeo; + +impl PlatformGeo for NoopGeo { + fn lookup(&self, _: Option) -> Result, Report> { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// build_runtime_services +// --------------------------------------------------------------------------- + +/// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. +/// +/// On native (host target, CI), all platform services degrade gracefully via +/// noop stubs. The Cloudflare Workers runtime uses the same path — actual KV, +/// config, and secret access is mediated by the edgezero dispatch layer via +/// handles, not through these platform trait impls. +pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { + let client_ip = extract_client_ip(ctx); + + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} + +fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option { + ctx.request() + .headers() + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()) +} diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs new file mode 100644 index 00000000..0c5cf589 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -0,0 +1,21 @@ +//! Smoke tests for the Cloudflare adapter route wiring. +//! +//! Runs on the host target (no Workers runtime). Verifies that +//! `TrustedServerApp::routes()` builds without panicking and that +//! the crate compiles cleanly on the host target. Does not exercise +//! the platform layer or outbound network calls. + +use edgezero_core::app::Hooks as _; +use trusted_server_adapter_cloudflare::app::TrustedServerApp; + +#[test] +fn routes_build_without_panic() { + // build_state() may fail (no real settings in CI) — startup_error_router + // is the fallback. Either way, routes() must not panic. + let _router = TrustedServerApp::routes(); +} + +#[test] +fn crate_compiles_on_host_target() { + // Ensures the cfg-gated shim keeps the crate host-compilable. +} diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml new file mode 100644 index 00000000..3d5a4fda --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -0,0 +1,11 @@ +name = "trusted-server" +main = "../../target/wasm32-unknown-unknown/release/trusted_server_adapter_cloudflare.wasm" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" + +[vars] +TRUSTED_SERVER_CONFIG = '{"publisher.domain":"your-publisher.com"}' diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index f12bf305..8e0f29d7 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -148,6 +148,43 @@ pub struct PlatformSelectResult { pub remaining: Vec, } +/// A [`PlatformHttpClient`] stand-in used when outbound HTTP is not available +/// on the current platform (e.g. Cloudflare Workers, where the proxy client is +/// managed by the edgezero dispatch layer instead). +/// +/// Every method returns [`PlatformError::HttpClient`], ensuring that code paths +/// that reach this stub receive a typed error. Adapter crates should use this +/// type rather than defining their own stub so the fallback behaviour is +/// consistent across all platform implementations. +pub struct UnavailableHttpClient; + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for UnavailableHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient) + .attach("HTTP client is unavailable on this platform")) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient) + .attach("HTTP client is unavailable on this platform")) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient) + .attach("HTTP client is unavailable on this platform")) + } +} + /// Outbound HTTP client abstraction. /// /// Supports both single-request sends ([`Self::send`]) and async fan-out diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 474aabbb..48054246 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -47,7 +47,7 @@ pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformK pub use error::PlatformError; pub use http::{ PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, + PlatformSelectResult, UnavailableHttpClient, }; pub use kv::UnavailableKvStore; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; From 4bbbd1dd1971496118d692a14697328b33a75e91 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 17 Apr 2026 14:05:38 +0530 Subject: [PATCH 3/9] Add CI job for Cloudflare adapter and update CLAUDE.md CI: test-cloudflare job checks native host compile, wasm32-unknown-unknown compile (with cloudflare feature), and runs host-target unit tests. CLAUDE.md: add cloudflare crate to workspace layout and build commands. --- .github/workflows/test.yml | 27 +++++++++++++++++++++++++++ CLAUDE.md | 12 +++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa86b537..ae4ce6e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,6 +69,33 @@ jobs: - name: Run Axum adapter tests run: cargo test -p trusted-server-adapter-axum + test-cloudflare: + name: cargo check (cloudflare native + wasm32-unknown-unknown) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain (native + wasm32-unknown-unknown) + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: wasm32-unknown-unknown + cache-shared-key: cargo-${{ runner.os }} + + - name: Check Cloudflare adapter (native host) + run: cargo check -p trusted-server-adapter-cloudflare + + - name: Check Cloudflare adapter (wasm32-unknown-unknown) + run: cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + + - name: Run Cloudflare adapter tests (native host) + run: cargo test -p trusted-server-adapter-cloudflare + test-typescript: name: vitest runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 42a36289..d7574d63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ real-time bidding integration, and publisher-side JavaScript injection. crates/ trusted-server-core/ # Core library — shared logic, integrations, HTML processing trusted-server-adapter-fastly/ # Fastly Compute entry point (wasm32-wasip1 binary) - trusted-server-adapter-axum/ # Axum dev server entry point (native binary, excluded from workspace) + trusted-server-adapter-axum/ # Axum dev server entry point (native binary) + trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -56,6 +57,15 @@ cargo run -p trusted-server-adapter-axum # Test Axum adapter only cargo test -p trusted-server-adapter-axum + +# Check Cloudflare adapter (native) +cargo check -p trusted-server-adapter-cloudflare + +# Check Cloudflare adapter (WASM target) +cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + +# Test Cloudflare adapter +cargo test -p trusted-server-adapter-cloudflare ``` ### Testing & Quality From d51af3b00c5dfc3ea829efceb0cbd44ba66e754f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 17 Apr 2026 14:09:38 +0530 Subject: [PATCH 4/9] Fix Cloudflare entry point: use worker 0.7 and pass manifest_src to run_app The rev (38198f9) of edgezero used in this workspace requires worker 0.7 (not 0.6) and run_app() takes a manifest_src: &str as first argument. Updated Cargo.toml and lib.rs accordingly. --- .../trusted-server-adapter-cloudflare/Cargo.toml | 2 +- .../trusted-server-adapter-cloudflare/src/lib.rs | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 4eb4dd28..39a813d0 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -23,7 +23,7 @@ error-stack = { workspace = true } log = { workspace = true } trusted-server-core = { path = "../trusted-server-core" } trusted-server-js = { path = "../js" } -worker = { version = "0.6", default-features = false, features = ["http"], optional = true } +worker = { version = "0.7", default-features = false, features = ["http"], optional = true } [dev-dependencies] edgezero-core = { workspace = true } diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index e9266b09..1d0a9047 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -6,10 +6,12 @@ use worker::*; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[event(fetch)] -pub async fn main( - req: Request, - env: Env, - ctx: Context, -) -> Result { - edgezero_adapter_cloudflare::run_app::(req, env, ctx).await +pub async fn main(req: Request, env: Env, ctx: Context) -> Result { + edgezero_adapter_cloudflare::run_app::( + include_str!("../cloudflare.toml"), + req, + env, + ctx, + ) + .await } From ae73797a17906daec0e965b6115e612687b478d2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 18 Apr 2026 18:36:53 +0530 Subject: [PATCH 5/9] Add CloudflareHttpClient, build.sh, wrangler DX, and review fixes - Implement CloudflareHttpClient (wasm32 only) using worker::Fetch for real outbound proxy requests; strip content-encoding/transfer-encoding headers since the Workers runtime auto-decompresses responses - Add build.sh with cd-to-SCRIPT_DIR guard so worker-build always runs from the correct crate root regardless of invocation directory - Switch wrangler dev task to use --cwd from workspace root (same DX as Fastly) - Add js-sys to workspace dependencies; reference it via { workspace = true } - Fix #[ignore] messages on Cloudflare integration tests - Replace std::time::{SystemTime,UNIX_EPOCH} with web_time in test code for signing.rs and proxy.rs (consistency with production paths) - Add NoopConfigStore/NoopSecretStore TODO comment tracking the gap - Add extract_client_ip unit tests (parses cf-connecting-ip, absent, invalid) - Remove empty crate_compiles_on_host_target test - Add CloudflareHttpClient timeout doc noting Workers CPU-budget tradeoff --- CLAUDE.md | 2 +- Cargo.lock | 156 ++- Cargo.toml | 1 + .../tests/environments/cloudflare.rs | 39 + .../tests/environments/mod.rs | 2 + crates/integration-tests/tests/integration.rs | 16 + crates/js/lib/package-lock.json | 47 +- .../.gitignore | 2 + .../Cargo.toml | 7 + .../build.sh | 20 + .../cloudflare.toml | 10 +- .../src/lib.rs | 10 +- .../src/platform.rs | 257 ++++- .../tests/routes.rs | 8 +- .../wrangler.toml | 9 +- crates/trusted-server-core/src/consent/mod.rs | 2 +- crates/trusted-server-core/src/proxy.rs | 6 +- .../src/request_signing/signing.rs | 8 +- .../2026-04-17-pr17-cloudflare-adapter.md | 964 ++++++++++++++++++ 19 files changed, 1506 insertions(+), 60 deletions(-) create mode 100644 crates/integration-tests/tests/environments/cloudflare.rs create mode 100644 crates/trusted-server-adapter-cloudflare/build.sh create mode 100644 docs/superpowers/plans/2026-04-17-pr17-cloudflare-adapter.md diff --git a/CLAUDE.md b/CLAUDE.md index d7574d63..bdb8ea8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ cargo test -p trusted-server-adapter-axum cargo check -p trusted-server-adapter-cloudflare # Check Cloudflare adapter (WASM target) -cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown # Test Cloudflare adapter cargo test -p trusted-server-adapter-cloudflare diff --git a/Cargo.lock b/Cargo.lock index 3d9a8faa..7a1ccdf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -937,6 +937,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "worker", +] + [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" @@ -1890,10 +1908,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1993,6 +2013,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -2985,6 +3011,17 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3589,6 +3626,22 @@ dependencies = [ "trusted-server-js", ] +[[package]] +name = "trusted-server-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "async-trait", + "edgezero-adapter-cloudflare", + "edgezero-core", + "error-stack", + "js-sys", + "log", + "tokio", + "trusted-server-core", + "trusted-server-js", + "worker", +] + [[package]] name = "trusted-server-adapter-fastly" version = "0.1.0" @@ -3656,6 +3709,7 @@ dependencies = [ "urlencoding", "uuid", "validator", + "web-time", ] [[package]] @@ -3842,9 +3896,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -3855,22 +3909,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3878,9 +3929,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -3891,18 +3942,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4267,6 +4331,64 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "worker" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "syn 2.0.111", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 04a09115..8a83a95a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ hmac = "0.12.1" http = "1.4.0" iab_gpp = "0.1" jose-jwk = "0.1.2" +js-sys = "0.3" log = "0.4.29" log-fastly = "0.11.12" lol_html = "2.7.2" diff --git a/crates/integration-tests/tests/environments/cloudflare.rs b/crates/integration-tests/tests/environments/cloudflare.rs new file mode 100644 index 00000000..d7ef2535 --- /dev/null +++ b/crates/integration-tests/tests/environments/cloudflare.rs @@ -0,0 +1,39 @@ +use crate::common::runtime::{RuntimeEnvironment, RuntimeProcess, TestError, TestResult}; +use error_stack::Report; +use std::path::Path; + +/// Cloudflare Workers runtime environment. +/// +/// Not runnable in the current integration test harness — Cloudflare Workers +/// requires a Wrangler dev server (`wrangler dev`) which is not automated here. +/// +/// All integration tests for this environment are marked `#[ignore]`. To run +/// them locally, start Wrangler first: +/// +/// ```sh +/// # Build the WASM binary +/// cargo build -p trusted-server-adapter-cloudflare --release --target wasm32-unknown-unknown --features cloudflare +/// +/// # Start the dev server (in crates/trusted-server-adapter-cloudflare/) +/// wrangler dev +/// ``` +/// +/// Then run with `-- --ignored test_wordpress_cloudflare`. +pub struct CloudflareWorkers; + +impl RuntimeEnvironment for CloudflareWorkers { + fn id(&self) -> &'static str { + "cloudflare" + } + + fn spawn(&self, _wasm_path: &Path) -> TestResult { + Err(Report::new(TestError::RuntimeSpawn).attach( + "Cloudflare Workers integration tests require a running `wrangler dev` instance \ + and are not automated in the CI harness. Run with --ignored to skip.", + )) + } + + fn health_check_path(&self) -> &str { + "/.well-known/trusted-server.json" + } +} diff --git a/crates/integration-tests/tests/environments/mod.rs b/crates/integration-tests/tests/environments/mod.rs index c3797e20..41b3d69c 100644 --- a/crates/integration-tests/tests/environments/mod.rs +++ b/crates/integration-tests/tests/environments/mod.rs @@ -1,4 +1,5 @@ pub mod axum; +pub mod cloudflare; pub mod fastly; use crate::common::runtime::{RuntimeEnvironment, TestError, TestResult}; @@ -22,6 +23,7 @@ type RuntimeFactory = fn() -> Box; pub static RUNTIME_ENVIRONMENTS: &[RuntimeFactory] = &[ || Box::new(fastly::FastlyViceroy), || Box::new(axum::AxumDevServer), + || Box::new(cloudflare::CloudflareWorkers), ]; /// Readiness polling configuration for runtimes and frontend containers. diff --git a/crates/integration-tests/tests/integration.rs b/crates/integration-tests/tests/integration.rs index 288f1685..be2518cb 100644 --- a/crates/integration-tests/tests/integration.rs +++ b/crates/integration-tests/tests/integration.rs @@ -135,6 +135,22 @@ fn test_nextjs_fastly() { test_combination(&runtime, &framework).expect("should pass Next.js on Fastly"); } +#[test] +#[ignore = "requires Docker and a running `wrangler dev` instance; see environments/cloudflare.rs"] +fn test_wordpress_cloudflare() { + let runtime = environments::cloudflare::CloudflareWorkers; + let framework = frameworks::wordpress::WordPress; + test_combination(&runtime, &framework).expect("should pass WordPress on Cloudflare Workers"); +} + +#[test] +#[ignore = "requires Docker and a running `wrangler dev` instance; see environments/cloudflare.rs"] +fn test_nextjs_cloudflare() { + let runtime = environments::cloudflare::CloudflareWorkers; + let framework = frameworks::nextjs::NextJs; + test_combination(&runtime, &framework).expect("should pass Next.js on Cloudflare Workers"); +} + #[test] #[ignore = "requires Docker and pre-built trusted-server-axum binary"] fn test_wordpress_axum() { diff --git a/crates/js/lib/package-lock.json b/crates/js/lib/package-lock.json index 6b4f3e21..4e972f90 100644 --- a/crates/js/lib/package-lock.json +++ b/crates/js/lib/package-lock.json @@ -27,6 +27,9 @@ "typescript-eslint": "^8.56.1", "vite": "^7.3.1", "vitest": "^4.0.8" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-x64": "^4.60.1" } }, "node_modules/@acemir/cssom": { @@ -99,6 +102,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1728,6 +1732,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1768,6 +1773,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2588,13 +2594,12 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3024,6 +3029,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3352,6 +3358,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3401,7 +3408,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3419,7 +3425,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3435,8 +3440,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-colors": { "version": "1.1.0", @@ -3847,6 +3851,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4707,6 +4712,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5317,8 +5323,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fdir": { "version": "6.5.0", @@ -7205,6 +7210,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7644,6 +7650,20 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -7743,7 +7763,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -7780,7 +7799,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7792,8 +7810,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", @@ -8535,6 +8552,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8763,6 +8781,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/crates/trusted-server-adapter-cloudflare/.gitignore b/crates/trusted-server-adapter-cloudflare/.gitignore index 6fd91c3b..801701c6 100644 --- a/crates/trusted-server-adapter-cloudflare/.gitignore +++ b/crates/trusted-server-adapter-cloudflare/.gitignore @@ -1,2 +1,4 @@ target/ .edgezero/ +.wrangler/ +.env.cloudflare.dev diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 39a813d0..ce9f6b4c 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -14,9 +14,11 @@ crate-type = ["cdylib", "rlib"] [features] default = [] +# Keep for explicit `cargo check --features cloudflare --target wasm32-unknown-unknown` cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] [dependencies] +async-trait = { workspace = true } edgezero-adapter-cloudflare = { workspace = true } edgezero-core = { workspace = true } error-stack = { workspace = true } @@ -25,6 +27,11 @@ trusted-server-core = { path = "../trusted-server-core" } trusted-server-js = { path = "../js" } worker = { version = "0.7", default-features = false, features = ["http"], optional = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +edgezero-adapter-cloudflare = { workspace = true, features = ["cloudflare"] } +js-sys = { workspace = true } +worker = { version = "0.7", default-features = false, features = ["http"] } + [dev-dependencies] edgezero-core = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh new file mode 100644 index 00000000..c6a042cf --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Source nvm so cargo's build.rs subprocess inherits the native arm64 Node. +# Without this, bash -lc tasks find the Rosetta x64 system node, which causes +# rollup to look for @rollup/rollup-darwin-x64 instead of the arm64 binary. +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Source the root .env (same values used by the Fastly and Axum dev tasks). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_ENV="$SCRIPT_DIR/../../.env" +[ -f "$ROOT_ENV" ] && set -a && source "$ROOT_ENV" && set +a + +# Allow cloudflare-specific overrides on top (not committed). +# Copy .env.cloudflare.dev.example → .env.cloudflare.dev to customise. +[ -f "$SCRIPT_DIR/.env.cloudflare.dev" ] && . "$SCRIPT_DIR/.env.cloudflare.dev" + +# worker-build must run from the crate root (where Cargo.toml lives) regardless +# of which directory wrangler was invoked from. +cd "$SCRIPT_DIR" +cargo install -q --version '^0.7' worker-build && worker-build --release diff --git a/crates/trusted-server-adapter-cloudflare/cloudflare.toml b/crates/trusted-server-adapter-cloudflare/cloudflare.toml index 0e81b15a..f505e6b2 100644 --- a/crates/trusted-server-adapter-cloudflare/cloudflare.toml +++ b/crates/trusted-server-adapter-cloudflare/cloudflare.toml @@ -7,13 +7,15 @@ kind = "http" [stores.kv] name = "trusted_server_kv" -[stores.kv.adapters] -cloudflare = "TRUSTED_SERVER_KV" + +[stores.kv.adapters.cloudflare] +name = "TRUSTED_SERVER_KV" [stores.config] name = "trusted_server_config" -[stores.config.adapters] -cloudflare = "TRUSTED_SERVER_CONFIG" + +[stores.config.adapters.cloudflare] +name = "TRUSTED_SERVER_CONFIG" [stores.secrets] name = "trusted_server_secrets" diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index 1d0a9047..7b4b587c 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -1,17 +1,21 @@ pub mod app; pub mod platform; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[cfg(target_arch = "wasm32")] use worker::*; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::( + match edgezero_adapter_cloudflare::run_app::( include_str!("../cloudflare.toml"), req, env, ctx, ) .await + { + Ok(resp) => Ok(resp), + Err(e) => Response::error(format!("worker dispatch error: {e}"), 500), + } } diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index ce126ae1..a25bb64d 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -4,14 +4,28 @@ use std::sync::Arc; use error_stack::Report; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableHttpClient, + PlatformGeo, PlatformHttpClient, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, }; +#[cfg(not(target_arch = "wasm32"))] +use trusted_server_core::platform::UnavailableHttpClient; + +#[cfg(target_arch = "wasm32")] +use error_stack::ResultExt as _; +#[cfg(target_arch = "wasm32")] +use trusted_server_core::platform::{ + PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, +}; + // --------------------------------------------------------------------------- // Noop stubs for host-target builds (native CI, unit tests) // --------------------------------------------------------------------------- +// TODO: wire edgezero-adapter-cloudflare's CloudflareConfigStore / CloudflareSecretStore +// for the wasm32 path so that key rotation (/admin/keys/rotate|deactivate) and +// signing config work on deployed Workers. Until then, all config/secret reads +// return errors on both native and WASM32 targets. struct NoopConfigStore; impl PlatformConfigStore for NoopConfigStore { @@ -64,25 +78,200 @@ impl PlatformGeo for NoopGeo { } } +// --------------------------------------------------------------------------- +// CloudflareHttpClient — WASM target only +// --------------------------------------------------------------------------- + +/// Carries a completed response through `send_async` → `select`. +/// +/// Same pattern as `AxumPendingResponse`: stores raw parts because `Body::Stream` +/// is `!Send`, which is incompatible with `Box` inside +/// [`PlatformPendingRequest`]. +#[cfg(target_arch = "wasm32")] +struct CloudflarePendingResponse { + backend_name: String, + status: u16, + headers: Vec<(String, Vec)>, + body: Vec, +} + +/// [`worker::Fetch`]-backed HTTP client for the Cloudflare Workers runtime. +/// +/// `send_async` eagerly awaits (sequential fan-out) — acceptable for Workers +/// since Cloudflare's own runtime handles true parallelism at the event level. +/// +/// Individual fetch calls have no explicit timeout. The Workers runtime enforces +/// a global CPU time limit per invocation (default 30 s wall-clock on paid plans) +/// which acts as an implicit upper bound. +#[cfg(target_arch = "wasm32")] +pub struct CloudflareHttpClient; + +#[cfg(target_arch = "wasm32")] +impl CloudflareHttpClient { + async fn execute( + &self, + request: PlatformHttpRequest, + ) -> Result> { + use worker::{Fetch, Headers, Method, Request, RequestInit}; + + let uri = request.request.uri().to_string(); + let method = Method::from(request.request.method().as_str().to_ascii_uppercase()); + + let headers = Headers::new(); + for (name, value) in request.request.headers() { + headers + .set(name.as_str(), &String::from_utf8_lossy(value.as_bytes())) + .change_context(PlatformError::HttpClient)?; + } + + let (_, body) = request.request.into_parts(); + let body_bytes = match body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + log::warn!( + "CloudflareHttpClient: Body::Stream is not supported; \ + outbound request body will be empty" + ); + vec![] + } + }; + + let mut init = RequestInit::new(); + init.with_method(method).with_headers(headers); + if !body_bytes.is_empty() { + let uint8 = js_sys::Uint8Array::from(body_bytes.as_slice()); + init.with_body(Some(uint8.into())); + } + + let worker_req = + Request::new_with_init(&uri, &init).change_context(PlatformError::HttpClient)?; + + let mut resp = Fetch::Request(worker_req) + .send() + .await + .change_context(PlatformError::HttpClient) + .attach_with(|| format!("outbound request to {uri} failed"))?; + + let status = resp.status_code(); + let mut edge_builder = edgezero_core::http::response_builder().status(status); + for (name, value) in resp.headers().entries() { + // The Workers runtime auto-decompresses gzip/br/deflate and handles + // chunked transfer — strip these headers so the proxy layer does not + // attempt a second decompression pass on the already-decoded body. + if matches!( + name.to_ascii_lowercase().as_str(), + "content-encoding" | "transfer-encoding" + ) { + continue; + } + edge_builder = edge_builder.header(name.as_str(), value.as_bytes()); + } + let body_bytes = resp + .bytes() + .await + .change_context(PlatformError::HttpClient)?; + let edge_resp = edge_builder + .body(edgezero_core::body::Body::from(body_bytes)) + .change_context(PlatformError::HttpClient)?; + + Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) + } +} + +#[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for CloudflareHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.execute(request).await + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend_name = request.backend_name.clone(); + let response = self.execute(request).await?; + + let status = response.response.status().as_u16(); + let headers: Vec<(String, Vec)> = response + .response + .headers() + .iter() + .map(|(n, v)| (n.to_string(), v.as_bytes().to_vec())) + .collect(); + let body_bytes = match response.response.into_body() { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => vec![], + }; + + let pending = CloudflarePendingResponse { + backend_name: backend_name.clone(), + status, + headers, + body: body_bytes, + }; + Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) + } + + async fn select( + &self, + mut pending_requests: Vec, + ) -> Result> { + if pending_requests.is_empty() { + return Err(Report::new(PlatformError::HttpClient) + .attach("select called with an empty pending_requests list")); + } + + let ready_platform = pending_requests.remove(0); + let pending = ready_platform + .downcast::() + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("unexpected inner type in CloudflareHttpClient::select") + })?; + + let mut builder = edgezero_core::http::response_builder().status(pending.status); + for (name, value) in &pending.headers { + builder = builder.header(name.as_str(), value.as_slice()); + } + let edge_resp = builder + .body(edgezero_core::body::Body::from(pending.body)) + .change_context(PlatformError::HttpClient)?; + + let ready = Ok(PlatformResponse::new(edge_resp).with_backend_name(pending.backend_name)); + Ok(PlatformSelectResult { + ready, + remaining: pending_requests, + }) + } +} + // --------------------------------------------------------------------------- // build_runtime_services // --------------------------------------------------------------------------- /// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. /// -/// On native (host target, CI), all platform services degrade gracefully via -/// noop stubs. The Cloudflare Workers runtime uses the same path — actual KV, -/// config, and secret access is mediated by the edgezero dispatch layer via -/// handles, not through these platform trait impls. +/// On native (host target, CI), the HTTP client degrades to [`UnavailableHttpClient`] +/// since `worker::Fetch` is only available on `wasm32`. On the Workers runtime the +/// real [`CloudflareHttpClient`] is used so outbound proxy requests succeed. pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { let client_ip = extract_client_ip(ctx); + #[cfg(target_arch = "wasm32")] + let http_client: Arc = Arc::new(CloudflareHttpClient); + #[cfg(not(target_arch = "wasm32"))] + let http_client: Arc = Arc::new(UnavailableHttpClient); + RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(NoopBackend)) - .http_client(Arc::new(UnavailableHttpClient)) + .http_client(http_client) .geo(Arc::new(NoopGeo)) .client_info(ClientInfo { client_ip, @@ -99,3 +288,59 @@ fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option RequestContext { + let req = request_builder() + .method("GET") + .uri("https://example.com/") + .header(name, HeaderValue::from_str(value).unwrap()) + .body(edgezero_core::body::Body::empty()) + .unwrap(); + RequestContext::new(req, PathParams::default()) + } + + fn make_ctx_without_header() -> RequestContext { + let req = request_builder() + .method("GET") + .uri("https://example.com/") + .body(edgezero_core::body::Body::empty()) + .unwrap(); + RequestContext::new(req, PathParams::default()) + } + + #[test] + fn extract_client_ip_parses_cf_connecting_ip() { + let ctx = make_ctx_with_header("cf-connecting-ip", "203.0.113.42"); + let ip = extract_client_ip(&ctx); + assert_eq!( + ip, + Some("203.0.113.42".parse().unwrap()), + "should parse cf-connecting-ip header" + ); + } + + #[test] + fn extract_client_ip_returns_none_when_header_absent() { + let ctx = make_ctx_without_header(); + assert!( + extract_client_ip(&ctx).is_none(), + "should return None when cf-connecting-ip is not set" + ); + } + + #[test] + fn extract_client_ip_returns_none_for_invalid_ip() { + let ctx = make_ctx_with_header("cf-connecting-ip", "not-an-ip"); + assert!( + extract_client_ip(&ctx).is_none(), + "should return None for an unparseable IP string" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 0c5cf589..d68123dd 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -1,8 +1,7 @@ //! Smoke tests for the Cloudflare adapter route wiring. //! //! Runs on the host target (no Workers runtime). Verifies that -//! `TrustedServerApp::routes()` builds without panicking and that -//! the crate compiles cleanly on the host target. Does not exercise +//! `TrustedServerApp::routes()` builds without panicking. Does not exercise //! the platform layer or outbound network calls. use edgezero_core::app::Hooks as _; @@ -14,8 +13,3 @@ fn routes_build_without_panic() { // is the fallback. Either way, routes() must not panic. let _router = TrustedServerApp::routes(); } - -#[test] -fn crate_compiles_on_host_target() { - // Ensures the cfg-gated shim keeps the crate host-compilable. -} diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 3d5a4fda..b5982c45 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -1,11 +1,18 @@ name = "trusted-server" -main = "../../target/wasm32-unknown-unknown/release/trusted_server_adapter_cloudflare.wasm" +main = "build/index.js" compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] +[build] +command = "bash build.sh" + [[kv_namespaces]] binding = "TRUSTED_SERVER_KV" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [vars] +# TRUSTED_SERVER_CONFIG is consumed by the edgezero layer as a JSON-encoded env +# var for local dev. On a deployed Worker this will be replaced by a proper KV +# namespace or config store binding once edgezero config-store wiring is added +# (tracked: NoopConfigStore in platform.rs). TRUSTED_SERVER_CONFIG = '{"publisher.domain":"your-publisher.com"}' diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 21b65256..e186356e 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -40,7 +40,7 @@ pub use types::{ ConsentContext, ConsentSource, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, }; -use std::time::{SystemTime, UNIX_EPOCH}; +use web_time::{SystemTime, UNIX_EPOCH}; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index a1568556..0c3cf60b 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -5,7 +5,8 @@ use error_stack::{Report, ResultExt}; use http::{header, HeaderValue, Method, Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use std::io::Cursor; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; +use web_time::{SystemTime, UNIX_EPOCH}; use crate::constants::{ HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER, @@ -1511,7 +1512,8 @@ mod tests { #[test] fn reconstruct_rejects_expired_tsexp() { futures::executor::block_on(async { - use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use std::time::Duration; + use web_time::{SystemTime, UNIX_EPOCH}; let settings = create_test_settings(); let tsurl = "https://cdn.example/asset.js"; diff --git a/crates/trusted-server-core/src/request_signing/signing.rs b/crates/trusted-server-core/src/request_signing/signing.rs index 6d78feab..f6c70b79 100644 --- a/crates/trusted-server-core/src/request_signing/signing.rs +++ b/crates/trusted-server-core/src/request_signing/signing.rs @@ -95,8 +95,8 @@ impl SigningParams { request_id, request_host, request_scheme, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + timestamp: web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0), } @@ -443,8 +443,8 @@ mod tests { "https".to_string(), ); - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let now_ms = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .expect("should get system time") .as_millis() as u64; diff --git a/docs/superpowers/plans/2026-04-17-pr17-cloudflare-adapter.md b/docs/superpowers/plans/2026-04-17-pr17-cloudflare-adapter.md new file mode 100644 index 00000000..8ae93a6a --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-pr17-cloudflare-adapter.md @@ -0,0 +1,964 @@ +# PR17 — Cloudflare Workers Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `trusted-server-adapter-cloudflare` crate so trusted-server runs on Cloudflare Workers, using the same `TrustedServerApp` core as the Fastly and Axum adapters. + +**Architecture:** A new `crates/trusted-server-adapter-cloudflare/` crate implements `Hooks` on `TrustedServerApp` and wires `RuntimeServices` using Cloudflare Workers bindings (KV, Config, Secrets) via the `edgezero-adapter-cloudflare` crate. The entry point is a `#[event(fetch)]` macro. Before adding the crate, `std::time::Instant` in `trusted-server-core` must be replaced with `web_time::Instant` (which is a zero-cost alias on native, but works on `wasm32-unknown-unknown` where `std::time::Instant` panics). The crate is host-compilable via `cfg`-gated shims so CI can validate it with `cargo check` on native before deploying to Workers. + +**Tech Stack:** Rust 2024 edition, `worker` crate (Cloudflare Workers SDK), `edgezero-adapter-cloudflare`, `web-time`, `wrangler` (CLI, for manual deploy only — not in CI). + +--- + +## File Map + +### New files + +- `crates/trusted-server-adapter-cloudflare/Cargo.toml` — crate manifest +- `crates/trusted-server-adapter-cloudflare/cloudflare.toml` — edgezero manifest (kv/config/secret store names) +- `crates/trusted-server-adapter-cloudflare/wrangler.toml` — Wrangler config (bindings, compatibility) +- `crates/trusted-server-adapter-cloudflare/.gitignore` — ignore `target/`, `.edgezero/` +- `crates/trusted-server-adapter-cloudflare/src/lib.rs` — `#[event(fetch)]` entry point + host shim +- `crates/trusted-server-adapter-cloudflare/src/app.rs` — `TrustedServerApp` + `Hooks` impl +- `crates/trusted-server-adapter-cloudflare/src/platform.rs` — `build_runtime_services` for Cloudflare +- `crates/trusted-server-adapter-cloudflare/tests/routes.rs` — route smoke tests (host target, no Workers runtime) + +### Modified files + +- `crates/trusted-server-core/Cargo.toml` — add `web-time` workspace dep +- `crates/trusted-server-core/src/auction/orchestrator.rs` — replace `std::time::Instant` with `web_time::Instant` +- `Cargo.toml` (workspace) — add `web-time` to `[workspace.dependencies]`; add cloudflare crate to `[members]` +- `.github/workflows/test.yml` — add `test-cloudflare` CI job +- `CLAUDE.md` — document new crate + +--- + +## Task 1: Replace `std::time::Instant` with `web_time::Instant` in core + +`std::time::Instant` panics on `wasm32-unknown-unknown` (Cloudflare). `web_time::Instant` is a zero-cost drop-in on native and JS-backed on WASM. + +**Files:** + +- Modify: `Cargo.toml` (workspace `[workspace.dependencies]`) +- Modify: `crates/trusted-server-core/Cargo.toml` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` + +- [ ] **Step 1: Add `web-time` to workspace dependencies** + +In `Cargo.toml`: + +```toml +web-time = "1" +``` + +Add alphabetically in `[workspace.dependencies]`. + +- [ ] **Step 2: Add `web-time` to `trusted-server-core/Cargo.toml`** + +```toml +web-time = { workspace = true } +``` + +- [ ] **Step 3: Replace `std::time::Instant` in orchestrator** + +In `crates/trusted-server-core/src/auction/orchestrator.rs`, change line 6: + +```rust +// Before: +use std::time::{Duration, Instant}; + +// After: +use std::time::Duration; +use web_time::Instant; +``` + +Lines 830 and 842 use `std::time::Instant::now()` — change both to `Instant::now()` (they already use the bare name once the import is replaced). + +- [ ] **Step 4: Verify WASM and native both compile** + +```bash +cargo check -p trusted-server-core +cargo check -p trusted-server-core --target wasm32-wasip1 +``` + +Expected: `Finished` with no errors. + +- [ ] **Step 5: Run core tests** + +```bash +cargo test -p trusted-server-core --target wasm32-wasip1 +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml crates/trusted-server-core/Cargo.toml crates/trusted-server-core/src/auction/orchestrator.rs +git commit -m "Replace std::time::Instant with web_time::Instant in auction orchestrator + +wasm32-unknown-unknown (Cloudflare Workers) does not support +std::time::Instant — it panics at runtime. web_time::Instant is a +zero-cost drop-in on native and JS-backed on WASM." +``` + +--- + +## Task 2: Workspace plumbing — add cloudflare crate as member + +**Files:** + +- Modify: `Cargo.toml` (workspace) +- Modify: `Cargo.toml` (workspace.dependencies) + +- [ ] **Step 1: Add `edgezero-adapter-cloudflare` to workspace deps** + +In `Cargo.toml` `[workspace.dependencies]`: + +```toml +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +``` + +(Same `rev` as the other edgezero deps already in the workspace.) + +- [ ] **Step 2: Add cloudflare crate to workspace `[members]`** + +```toml +members = [ + "crates/trusted-server-core", + "crates/trusted-server-adapter-fastly", + "crates/trusted-server-adapter-axum", + "crates/trusted-server-adapter-cloudflare", + "crates/js", + "crates/openrtb", +] +``` + +- [ ] **Step 3: Verify workspace resolves (crate doesn't exist yet — expect path error)** + +```bash +cargo metadata --no-deps 2>&1 | head -5 +``` + +Expected: error about missing path (that's fine — the crate directory doesn't exist yet). Proceed to Task 3. + +--- + +## Task 3: Crate skeleton + +**Files:** + +- Create: `crates/trusted-server-adapter-cloudflare/.gitignore` +- Create: `crates/trusted-server-adapter-cloudflare/Cargo.toml` +- Create: `crates/trusted-server-adapter-cloudflare/src/lib.rs` +- Create: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Create: `crates/trusted-server-adapter-cloudflare/src/platform.rs` + +- [ ] **Step 1: Create `.gitignore`** + +``` +target/ +.edgezero/ +``` + +- [ ] **Step 2: Create `Cargo.toml`** + +```toml +[package] +name = "trusted-server-adapter-cloudflare" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true + +[lib] +name = "trusted_server_adapter_cloudflare" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] + +[dependencies] +async-trait = { workspace = true } +edgezero-adapter-cloudflare = { workspace = true, features = [] } +edgezero-core = { workspace = true } +error-stack = { workspace = true } +log = { workspace = true } +trusted-server-core = { path = "../trusted-server-core" } +trusted-server-js = { path = "../js" } +worker = { version = "0.7", default-features = false, features = ["http"], optional = true } + +[dev-dependencies] +edgezero-adapter-cloudflare = { workspace = true } +edgezero-core = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tower = { version = "0.4", features = ["util"] } +``` + +- [ ] **Step 3: Create stub `src/lib.rs`** + +```rust +pub mod app; +pub mod platform; +``` + +- [ ] **Step 4: Create stub `src/app.rs`** + +```rust +use trusted_server_core::error::TrustedServerError; +use error_stack::Report; + +/// Application entry point (stub — implementation in Task 4). +pub struct TrustedServerApp; + +pub(crate) fn http_error(_report: &Report) -> edgezero_core::http::Response { + todo!("implemented in Task 4") +} +``` + +- [ ] **Step 5: Create stub `src/platform.rs`** + +```rust +use trusted_server_core::platform::RuntimeServices; + +pub fn build_runtime_services( + _ctx: &edgezero_core::context::RequestContext, +) -> RuntimeServices { + todo!("implemented in Task 5") +} +``` + +- [ ] **Step 6: Verify workspace compiles** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished` (stubs compile, `todo!()` is fine at check time). + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/ Cargo.toml +git commit -m "Add trusted-server-adapter-cloudflare crate skeleton" +``` + +--- + +## Task 4: App wiring — `TrustedServerApp` + `Hooks` implementation + +This mirrors `crates/trusted-server-adapter-axum/src/app.rs` exactly, except the entry point and error helper. + +**Files:** + +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` + +- [ ] **Step 1: Write the full `app.rs`** + +```rust +use std::sync::Arc; + +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderValue, Response, header}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +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, +}; +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::platform::build_runtime_services; + +pub struct AppState { + settings: Arc, + orchestrator: Arc, + registry: Arc, +} + +fn build_state() -> Result, Report> { + let settings = get_settings()?; + let orchestrator = build_orchestrator(&settings)?; + let registry = IntegrationRegistry::new(&settings)?; + Ok(Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + })) +} + +fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx) +} + +/// Convert a [`Report`] into an HTTP [`Response`]. +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 +} + +fn startup_error_router(e: Report) -> RouterService { + RouterService::new(move |_ctx: RequestContext| { + let body = edgezero_core::body::Body::from(format!( + "trusted-server failed to start: {}\n", + e.current_context() + )); + let mut r = Response::new(body); + *r.status_mut() = edgezero_core::http::StatusCode::INTERNAL_SERVER_ERROR; + async move { Ok(r) } + }) +} + +pub struct TrustedServerApp; + +impl Hooks for TrustedServerApp { + fn routes() -> RouterService { + let state = match build_state() { + Ok(s) => s, + Err(e) => return startup_error_router(e), + }; + + let settings = Arc::clone(&state.settings); + let orchestrator = Arc::clone(&state.orchestrator); + let registry = Arc::clone(&state.registry); + + let mut router = edgezero_core::router::Router::new(); + + // Discovery + signing + { + let s = Arc::clone(&settings); + router.get("/.well-known/trusted-server.json", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_trusted_server_discovery(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.post("/verify-signature", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_verify_signature(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Admin + { + let s = Arc::clone(&settings); + router.post("/admin/keys/rotate", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_rotate_key(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.post("/admin/keys/deactivate", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_deactivate_key(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Static JS + { + let s = Arc::clone(&settings); + let r = Arc::clone(®istry); + router.get("/static/tsjs=:hash", move |ctx| { + let s = Arc::clone(&s); + let r = Arc::clone(&r); + async move { handle_tsjs_dynamic(&ctx, &s, &r).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // First-party proxy + { + let s = Arc::clone(&settings); + router.get("/first-party/proxy", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.post("/first-party/proxy", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.get("/first-party/proxy/sign", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy_sign(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.get("/first-party/proxy/rebuild", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_proxy_rebuild(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + { + let s = Arc::clone(&settings); + router.get("/first-party/click", move |ctx| { + let s = Arc::clone(&s); + let svc = build_per_request_services(&ctx); + async move { handle_first_party_click(&ctx, &s, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Auction + { + let s = Arc::clone(&settings); + let o = Arc::clone(&orchestrator); + router.post("/auction", move |ctx| { + let s = Arc::clone(&s); + let o = Arc::clone(&o); + let svc = build_per_request_services(&ctx); + async move { handle_auction(&ctx, &s, &o, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + // Publisher proxy (catch-all) + { + let s = Arc::clone(&settings); + let r = Arc::clone(®istry); + router.any("/:path*", move |ctx| { + let s = Arc::clone(&s); + let r = Arc::clone(&r); + let svc = build_per_request_services(&ctx); + async move { handle_publisher_request(&ctx, &s, &r, &svc).await.or_else(|e| Ok(http_error(&e))) } + }); + } + + router.build() + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/src/app.rs +git commit -m "Add TrustedServerApp Hooks implementation for Cloudflare adapter" +``` + +--- + +## Task 5: Platform trait implementations + +Cloudflare Workers exposes KV, config, and secrets through the `worker::Env` binding. The edgezero Cloudflare adapter already wraps these — we just need to wire them into `RuntimeServices`. + +**Key difference from Axum:** On Cloudflare the `worker::Env` is passed per-request via `CloudflareRequestContext`. KV is available via the edgezero adapter's built-in handle; config/secret use `edgezero-adapter-cloudflare`'s `CloudflareConfigStore` and `CloudflareSecretStore`. For the `PlatformHttpClient`, the edgezero adapter's `CloudflareProxyClient` is already registered at dispatch time via `ProxyHandle` — so we use `UnavailableHttpClient` (same pattern as Axum's `UnavailableKvStore`). + +**Files:** + +- Modify: `crates/trusted-server-adapter-cloudflare/src/platform.rs` + +- [ ] **Step 1: Write `platform.rs`** + +On native (host compile for CI), the `worker` crate types are unavailable. Use `cfg` to gate the Cloudflare-specific implementation behind `#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))]` and provide a no-op stub for host builds. + +```rust +use std::sync::Arc; +use trusted_server_core::platform::{ + PlatformError, RuntimeServices, UnavailableKvStore, + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformGeo, GeoInfo, StoreName, StoreId, +}; +use error_stack::Report; + +// --------------------------------------------------------------------------- +// Host-only stub (native target, used in CI cargo check + tests) +// --------------------------------------------------------------------------- + +/// Construct a no-op [`RuntimeServices`] for host-target builds. +/// +/// All platform operations degrade gracefully on native. This exists only so +/// the crate host-compiles for CI; Cloudflare Workers always runs the +/// `cfg`-gated implementation below. +#[cfg(not(all(feature = "cloudflare", target_arch = "wasm32")))] +pub fn build_runtime_services( + _ctx: &edgezero_core::context::RequestContext, +) -> RuntimeServices { + struct NoopConfigStore; + impl PlatformConfigStore for NoopConfigStore { + fn get(&self, _: &StoreName, _: &str) -> Result> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + } + } + + struct NoopSecretStore; + impl trusted_server_core::platform::PlatformSecretStore for NoopSecretStore { + fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + } + } + + struct NoopBackend; + impl PlatformBackend for NoopBackend { + fn predict_name(&self, _: &PlatformBackendSpec) -> Result> { + Ok("noop".to_string()) + } + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } + } + + struct NoopGeo; + impl PlatformGeo for NoopGeo { + fn lookup(&self, _: Option) -> Result, Report> { + Ok(None) + } + } + + use trusted_server_core::platform::UnavailableHttpClient; + + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { client_ip: None, tls_protocol: None, tls_cipher: None }) + .build() +} + +// --------------------------------------------------------------------------- +// Cloudflare Workers implementation +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, +) -> RuntimeServices { + use edgezero_adapter_cloudflare::CloudflareRequestContext; + + let client_ip = CloudflareRequestContext::get(ctx.request()) + .and_then(|c| c.client_ip()); + + // KV, config, secrets are injected at dispatch time by edgezero's + // dispatch_with_bindings — they live in the request extensions. + // UnavailableKvStore and UnavailableHttpClient are correct here: + // KV is accessed via edgezero's KvHandle (not PlatformKvStore), + // and outbound HTTP uses CloudflareProxyClient via ProxyHandle. + use trusted_server_core::platform::UnavailableHttpClient; + + struct CloudflareBackend; + impl PlatformBackend for CloudflareBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + Ok(format!("{}_{}", spec.scheme, spec.host)) + } + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } + } + + struct CloudflareGeo; + impl PlatformGeo for CloudflareGeo { + fn lookup(&self, _: Option) -> Result, Report> { + // Cloudflare geo is available via cf-ipcountry header; not yet wired. + Ok(None) + } + } + + struct UnavailableConfigStore; + impl PlatformConfigStore for UnavailableConfigStore { + fn get(&self, _: &StoreName, _: &str) -> Result> { + Err(Report::new(PlatformError::ConfigStore).attach("use edgezero config store handle")) + } + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("writes not supported")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("deletes not supported")) + } + } + + struct UnavailableSecretStore; + impl trusted_server_core::platform::PlatformSecretStore for UnavailableSecretStore { + fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + Err(Report::new(PlatformError::SecretStore).attach("use edgezero secret handle")) + } + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("writes not supported")) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("deletes not supported")) + } + } + + RuntimeServices::builder() + .config_store(Arc::new(UnavailableConfigStore)) + .secret_store(Arc::new(UnavailableSecretStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(CloudflareBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(CloudflareGeo)) + .client_info(ClientInfo { + client_ip, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} +``` + +- [ ] **Step 2: Verify host compiles** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/src/platform.rs +git commit -m "Add Cloudflare platform trait implementations (cfg-gated)" +``` + +--- + +## Task 6: Entry point — `#[event(fetch)]` + cloudflare manifest + +**Files:** + +- Modify: `crates/trusted-server-adapter-cloudflare/src/lib.rs` +- Create: `crates/trusted-server-adapter-cloudflare/cloudflare.toml` +- Create: `crates/trusted-server-adapter-cloudflare/wrangler.toml` + +- [ ] **Step 1: Write the full `lib.rs`** + +```rust +pub mod app; +pub mod platform; + +/// Host-target shim — keeps the crate compilable on native for CI. +/// +/// The real `#[event(fetch)]` entry point is gated to +/// `cfg(all(feature = "cloudflare", target_arch = "wasm32"))`. +#[cfg(not(all(feature = "cloudflare", target_arch = "wasm32")))] +pub fn _host_build_shim() {} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::*; + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[event(fetch)] +pub async fn main( + req: Request, + env: Env, + ctx: Context, +) -> Result { + edgezero_adapter_cloudflare::run_app::( + include_str!("../cloudflare.toml"), + req, + env, + ctx, + ) + .await +} +``` + +- [ ] **Step 2: Create `cloudflare.toml`** (edgezero manifest) + +```toml +[app] +name = "trusted-server" +version = "0.1.0" +kind = "http" + +[adapters.cloudflare] + +[stores.kv] +name = "trusted_server_kv" +[stores.kv.adapters] +cloudflare = "TRUSTED_SERVER_KV" + +[stores.config] +name = "trusted_server_config" +[stores.config.adapters] +cloudflare = "TRUSTED_SERVER_CONFIG" + +[stores.secrets] +name = "trusted_server_secrets" +[stores.secrets.adapters.cloudflare] +enabled = true +``` + +- [ ] **Step 3: Create `wrangler.toml`** + +```toml +name = "trusted-server" +main = "../../target/wasm32-unknown-unknown/release/trusted_server_adapter_cloudflare.wasm" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" + +[vars] +TRUSTED_SERVER_CONFIG = '{"publisher.domain":"your-publisher.com"}' +``` + +- [ ] **Step 4: Verify host compiles with lib changes** + +```bash +cargo check -p trusted-server-adapter-cloudflare +``` + +Expected: `Finished`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/src/lib.rs \ + crates/trusted-server-adapter-cloudflare/cloudflare.toml \ + crates/trusted-server-adapter-cloudflare/wrangler.toml +git commit -m "Add Cloudflare Workers entry point and wrangler config" +``` + +--- + +## Task 7: Route smoke tests (host target) + +Same pattern as `trusted-server-adapter-axum/tests/routes.rs`. Uses `EdgeZeroAxumService` — wait, Cloudflare doesn't have an axum-style in-process service. Instead we test `TrustedServerApp::routes()` returns a valid `RouterService` by calling it on the host, without any Workers runtime. + +**Files:** + +- Create: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` + +- [ ] **Step 1: Write `tests/routes.rs`** + +```rust +//! Smoke tests for the Cloudflare adapter route wiring. +//! +//! Runs on the host target (no Workers runtime). Verifies that +//! TrustedServerApp::routes() builds without panicking and that +//! the expected routes exist. Does not exercise the platform layer. + +use edgezero_core::app::Hooks as _; +use trusted_server_adapter_cloudflare::app::TrustedServerApp; + +#[test] +fn routes_build_without_panic() { + // build_state() may fail (no real settings on CI) — startup_error_router + // is the fallback. Either way, routes() must not panic. + let _router = TrustedServerApp::routes(); +} + +#[test] +fn crate_compiles_on_host_target() { + // Ensures the cfg-gated shim keeps the crate host-compilable. +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test -p trusted-server-adapter-cloudflare +``` + +Expected: `test result: ok. 2 passed`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-cloudflare/tests/routes.rs +git commit -m "Add Cloudflare adapter smoke tests (host target)" +``` + +--- + +## Task 8: CI workflow + +**Files:** + +- Modify: `.github/workflows/test.yml` + +- [ ] **Step 1: Add `test-cloudflare` job** + +After the existing `test-axum` job, add: + +```yaml +test-cloudflare: + name: cargo check (cloudflare native + wasm32) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain (native + wasm32-unknown-unknown) + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: wasm32-unknown-unknown + cache-shared-key: cargo-${{ runner.os }} + + - name: Check Cloudflare adapter (native host) + run: cargo check -p trusted-server-adapter-cloudflare + + - name: Check Cloudflare adapter (wasm32-unknown-unknown) + run: cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + + - name: Run Cloudflare adapter tests (native host) + run: cargo test -p trusted-server-adapter-cloudflare +``` + +- [ ] **Step 2: Verify test.yml is valid YAML** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml'))" && echo "valid" +``` + +Expected: `valid`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/test.yml +git commit -m "Add CI job for Cloudflare adapter (native check + wasm32-unknown-unknown check + tests)" +``` + +--- + +## Task 9: CLAUDE.md update + +**Files:** + +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add Cloudflare to workspace layout table** + +In the `## Workspace Layout` section, add: + +``` + trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) +``` + +- [ ] **Step 2: Add build commands** + +In `## Build & Test Commands`, under `### Rust`: + +```bash +# Check Cloudflare adapter (native) +cargo check -p trusted-server-adapter-cloudflare + +# Check Cloudflare adapter (WASM target) +cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + +# Test Cloudflare adapter +cargo test -p trusted-server-adapter-cloudflare +``` + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "Update CLAUDE.md: add Cloudflare adapter to workspace layout and commands" +``` + +--- + +## Task 10: Full verification pass + +- [ ] **Step 1: Format check** + +```bash +cargo fmt --all -- --check +``` + +Expected: no output (clean). + +- [ ] **Step 2: Clippy** + +```bash +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Expected: `Finished`. + +- [ ] **Step 3: Full test suite** + +```bash +cargo test --workspace --exclude trusted-server-adapter-axum --target wasm32-wasip1 +cargo test -p trusted-server-adapter-axum +cargo test -p trusted-server-adapter-cloudflare +``` + +Expected: all pass. + +- [ ] **Step 4: JS tests** + +```bash +cd crates/js/lib && npm run build && npm test -- --run +``` + +Expected: all pass. + +- [ ] **Step 5: Verify cloudflare WASM target check** + +```bash +cargo check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +``` + +Expected: `Finished` (no panics, no unsupported types). From 356506b64d8f463b0392317e97c69e810276ad85 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 18 Apr 2026 19:25:55 +0530 Subject: [PATCH 6/9] Wire Cloudflare platform stores via edgezero handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct worker::Env store construction with edgezero handles already injected by run_app, reducing #[cfg(target_arch = "wasm32")] blocks from 5 to 2. - ConfigStoreHandleAdapter: bridges ctx.config_store() to PlatformConfigStore — reuses the already-parsed TRUSTED_SERVER_CONFIG JSON handle rather than re-parsing it on every request - KvHandleAdapter: bridges ctx.kv_handle() to PlatformKvStore — reuses the env.kv() handle opened by run_app rather than opening a new one - CloudflareGeo: moved outside #[cfg]; reads cf-ipcountry and related headers via ctx.request().headers() which needs no platform import - CloudflareSecretStoreAdapter: kept under #[cfg] — env.secret() is synchronous at the JS level but PlatformSecretStore::get_bytes is sync while SecretHandle::get_bytes is async; direct env access is required - Add .dev.vars to .gitignore (wrangler convention for local secrets) - Add bytes workspace dep for KvStore impl --- Cargo.lock | 1 + .../.gitignore | 2 + .../Cargo.toml | 1 + .../src/platform.rs | 311 ++++++++++++++++-- 4 files changed, 290 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a1ccdf0..662df781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3631,6 +3631,7 @@ name = "trusted-server-adapter-cloudflare" version = "0.1.0" dependencies = [ "async-trait", + "bytes", "edgezero-adapter-cloudflare", "edgezero-core", "error-stack", diff --git a/crates/trusted-server-adapter-cloudflare/.gitignore b/crates/trusted-server-adapter-cloudflare/.gitignore index 801701c6..f3cade26 100644 --- a/crates/trusted-server-adapter-cloudflare/.gitignore +++ b/crates/trusted-server-adapter-cloudflare/.gitignore @@ -1,4 +1,6 @@ target/ +build/ .edgezero/ .wrangler/ .env.cloudflare.dev +.dev.vars diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index ce9f6b4c..0cc3a0c4 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -19,6 +19,7 @@ cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] [dependencies] async-trait = { workspace = true } +bytes = { workspace = true } edgezero-adapter-cloudflare = { workspace = true } edgezero-core = { workspace = true } error-stack = { workspace = true } diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index a25bb64d..48b640b8 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -1,11 +1,14 @@ use std::net::IpAddr; use std::sync::Arc; +use std::time::Duration; +use bytes::Bytes; +use edgezero_core::{ConfigStoreHandle, KvHandle, KvPage, KvStore}; use error_stack::Report; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformSecretStore, RuntimeServices, StoreId, StoreName, - UnavailableKvStore, + ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, + RuntimeServices, StoreId, StoreName, UnavailableKvStore, }; #[cfg(not(target_arch = "wasm32"))] @@ -19,26 +22,22 @@ use trusted_server_core::platform::{ }; // --------------------------------------------------------------------------- -// Noop stubs for host-target builds (native CI, unit tests) +// Noop stubs — used when a handle is absent (native CI, missing binding) // --------------------------------------------------------------------------- -// TODO: wire edgezero-adapter-cloudflare's CloudflareConfigStore / CloudflareSecretStore -// for the wasm32 path so that key rotation (/admin/keys/rotate|deactivate) and -// signing config work on deployed Workers. Until then, all config/secret reads -// return errors on both native and WASM32 targets. struct NoopConfigStore; impl PlatformConfigStore for NoopConfigStore { fn get(&self, _: &StoreName, _: &str) -> Result> { - Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) } fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) } fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore).attach("unavailable on host target")) + Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) } } @@ -46,15 +45,15 @@ struct NoopSecretStore; impl PlatformSecretStore for NoopSecretStore { fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { - Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) } fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) } fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::SecretStore).attach("unavailable on host target")) + Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) } } @@ -70,11 +69,89 @@ impl PlatformBackend for NoopBackend { } } -struct NoopGeo; +// --------------------------------------------------------------------------- +// edgezero handle adapters — no #[cfg] needed; platform-specific store +// construction is handled by edgezero's run_app before we receive the ctx. +// --------------------------------------------------------------------------- + +/// Bridges edgezero's [`ConfigStoreHandle`] (injected by `run_app` from the +/// `TRUSTED_SERVER_CONFIG` env-var binding) to [`PlatformConfigStore`]. +/// +/// Reads delegate through the handle. Writes are unsupported on all current +/// adapter targets and return errors. +/// +/// Note: Cloudflare config is a single flat JSON env-var binding — all keys +/// live in one namespace. The `store_name` argument is intentionally ignored; +/// callers cannot route to a different store by passing a different name. +struct ConfigStoreHandleAdapter(ConfigStoreHandle); + +impl PlatformConfigStore for ConfigStoreHandleAdapter { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.0 + .get(key) + .map_err(|e| { + Report::new(PlatformError::ConfigStore) + .attach(format!("config store lookup failed: {e}")) + })? + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore).attach(format!("key not found: {key}")) + }) + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported")) + } +} + +/// Bridges edgezero's [`KvHandle`] (injected by `run_app` from the +/// `TRUSTED_SERVER_KV` KV namespace binding) to [`PlatformKvStore`]. +/// +/// Delegates all operations through `KvHandle`'s raw-bytes API, which includes +/// key/value validation before forwarding to the underlying store. +/// +/// Note: key/value validation runs twice — once inside this `KvHandle` and once +/// inside the `KvHandle` that `RuntimeServices::kv_handle()` constructs from +/// this adapter. The overhead is negligible (string length checks only) and +/// avoided by the fact that we reuse the already-opened `env.kv()` handle from +/// `run_app` rather than opening a new one. +struct KvHandleAdapter(KvHandle); + +#[async_trait::async_trait(?Send)] +impl KvStore for KvHandleAdapter { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.0.get_bytes(key).await + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.0.put_bytes(key, value).await + } -impl PlatformGeo for NoopGeo { - fn lookup(&self, _: Option) -> Result, Report> { - Ok(None) + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.0.put_bytes_with_ttl(key, value, ttl).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.0.delete(key).await + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.0.list_keys_page(prefix, cursor, limit).await } } @@ -249,15 +326,74 @@ impl PlatformHttpClient for CloudflareHttpClient { } } +// --------------------------------------------------------------------------- +// CloudflareSecretStoreAdapter — WASM target only +// +// Secrets are the one platform surface that cannot be bridged through an +// edgezero handle: `SecretHandle::get_bytes` is async, but +// `PlatformSecretStore::get_bytes` is sync. The Cloudflare `env.secret()` +// call IS synchronous at the JS level, so we call it directly here. +// --------------------------------------------------------------------------- + +/// Bridges [`worker::Env`] secrets to [`PlatformSecretStore`] by calling +/// `env.secret(key)` synchronously. Writes and deletes return errors. +#[cfg(target_arch = "wasm32")] +struct CloudflareSecretStoreAdapter { + env: worker::Env, +} + +#[cfg(target_arch = "wasm32")] +impl PlatformSecretStore for CloudflareSecretStoreAdapter { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + use worker::Error as WorkerError; + match self.env.secret(key) { + Ok(secret) => Ok(secret.to_string().into_bytes()), + Err(WorkerError::BindingError(_)) => Err(Report::new(PlatformError::SecretStore) + .attach(format!("secret binding not found: {key}"))), + Err(WorkerError::JsError(msg)) + if msg.contains("does not contain binding") || msg.contains("is undefined") => + { + Err(Report::new(PlatformError::SecretStore) + .attach(format!("secret not found: {key}"))) + } + Err(err) => Err(Report::new(PlatformError::SecretStore) + .attach(format!("secret lookup failed: {err}"))), + } + } + + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore) + .attach("secret store writes are not supported on Cloudflare Workers")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore) + .attach("secret store writes are not supported on Cloudflare Workers")) + } +} + // --------------------------------------------------------------------------- // build_runtime_services // --------------------------------------------------------------------------- /// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. /// -/// On native (host target, CI), the HTTP client degrades to [`UnavailableHttpClient`] -/// since `worker::Fetch` is only available on `wasm32`. On the Workers runtime the -/// real [`CloudflareHttpClient`] is used so outbound proxy requests succeed. +/// Config and KV are sourced from the edgezero handles that `run_app` injects +/// before routing — via the `TRUSTED_SERVER_CONFIG` env-var binding and the +/// `TRUSTED_SERVER_KV` KV namespace declared in `cloudflare.toml`. No +/// platform-specific `#[cfg]` is required for these two stores. +/// +/// Secrets still require direct `worker::Env` access because +/// `SecretHandle::get_bytes` is async while `PlatformSecretStore::get_bytes` +/// is sync; the underlying `env.secret()` call is synchronous at the JS level. +/// +/// Geo information is read from Cloudflare's injected request headers +/// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on +/// the native host target simply produce empty/zero defaults. pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -266,13 +402,42 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); + // Config: use the ConfigStoreHandle injected by run_app — no #[cfg] needed. + let config_store: Arc = ctx + .config_store() + .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) + .unwrap_or_else(|| Arc::new(NoopConfigStore)); + + // KV: use the KvHandle injected by run_app — no #[cfg] needed. + let kv_store: Arc = ctx + .kv_handle() + .map(|h| Arc::new(KvHandleAdapter(h)) as Arc) + .unwrap_or_else(|| Arc::new(UnavailableKvStore)); + + // Secrets: still requires wasm32-specific env.secret() (async/sync mismatch). + #[cfg(target_arch = "wasm32")] + let secret_store: Arc = + edgezero_adapter_cloudflare::CloudflareRequestContext::get(ctx.request()) + .map(|cf_ctx| { + Arc::new(CloudflareSecretStoreAdapter { + env: cf_ctx.env().clone(), + }) as Arc + }) + .unwrap_or_else(|| Arc::new(NoopSecretStore)); + #[cfg(not(target_arch = "wasm32"))] + let secret_store: Arc = Arc::new(NoopSecretStore); + + // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are + // simply absent on the native host target, producing Ok(None) from lookup(). + let geo = build_geo(ctx); + RuntimeServices::builder() - .config_store(Arc::new(NoopConfigStore)) - .secret_store(Arc::new(NoopSecretStore)) - .kv_store(Arc::new(UnavailableKvStore)) + .config_store(config_store) + .secret_store(secret_store) + .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(geo)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -281,6 +446,78 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .build() } +// --------------------------------------------------------------------------- +// Geo — reads Cloudflare-injected request headers (no #[cfg] needed) +// --------------------------------------------------------------------------- + +/// Reads Cloudflare geo headers injected by the Workers runtime. +/// +/// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, +/// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or +/// unparseable values default to empty strings or `0.0`. Country code `XX` +/// (Cloudflare's "unknown" sentinel) is treated as absent. +struct CloudflareGeo { + country: String, + city: String, + continent: String, + latitude: f64, + longitude: f64, +} + +impl PlatformGeo for CloudflareGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + if self.country.is_empty() { + return Ok(None); + } + Ok(Some(GeoInfo { + city: self.city.clone(), + country: self.country.clone(), + continent: self.continent.clone(), + latitude: self.latitude, + longitude: self.longitude, + metro_code: 0, + region: None, + })) + } +} + +fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { + let headers = ctx.request().headers(); + let country = headers + .get("cf-ipcountry") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty() && *s != "XX") + .unwrap_or("") + .to_string(); + let city = headers + .get("cf-ipcity") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let continent = headers + .get("cf-ipcontinent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let latitude = headers + .get("cf-iplatitude") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + let longitude = headers + .get("cf-iplongitude") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + CloudflareGeo { + country, + city, + continent, + latitude, + longitude, + } +} + fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option { ctx.request() .headers() @@ -343,4 +580,28 @@ mod tests { "should return None for an unparseable IP string" ); } + + #[test] + fn build_geo_returns_country_from_header() { + let ctx = make_ctx_with_header("cf-ipcountry", "US"); + let geo = build_geo(&ctx); + assert_eq!(geo.country, "US", "should extract cf-ipcountry"); + } + + #[test] + fn build_geo_treats_xx_as_absent() { + let ctx = make_ctx_with_header("cf-ipcountry", "XX"); + let geo = build_geo(&ctx); + assert!(geo.country.is_empty(), "XX should be treated as absent"); + } + + #[test] + fn build_geo_lookup_returns_none_when_country_absent() { + let ctx = make_ctx_without_header(); + let geo = build_geo(&ctx); + assert!( + geo.lookup(None).unwrap().is_none(), + "should return None when no country header" + ); + } } From 50a69caa4c3eb732f960bd7a64696d090f8a70d8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 18 Apr 2026 19:55:32 +0530 Subject: [PATCH 7/9] Add Cloudflare Workers to CI integration tests - Implement CloudflareWorkers::spawn() to start wrangler dev; in CI uses wrangler.ci.toml (no build step, uses pre-built bundle); locally uses wrangler.toml (triggers build.sh rebuild) - Add wrangler.ci.toml: no [build] section so wrangler dev skips the worker-build step when the bundle is pre-built as a CI artifact - Add build-cloudflare input to setup-integration-test-env: adds wasm32-unknown-unknown target and runs build.sh with integration test env vars - In prepare-artifacts: enable build-cloudflare and upload build/ dir as artifact - In integration-tests: restore CF build dir, install wrangler, set CLOUDFLARE_WRANGLER_DIR, and remove --skip flags for cloudflare tests --- .../setup-integration-test-env/action.yml | 19 +++ .github/workflows/integration-tests.yml | 26 ++++- .../tests/environments/cloudflare.rs | 108 +++++++++++++++--- .../wrangler.ci.toml | 14 +++ 4 files changed, 145 insertions(+), 22 deletions(-) create mode 100644 crates/trusted-server-adapter-cloudflare/wrangler.ci.toml diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index fcbaee06..858b5947 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -25,6 +25,10 @@ inputs: description: Build the framework Docker images used by integration tests. required: false default: "true" + build-cloudflare: + description: Build the Cloudflare Workers bundle (wasm32-unknown-unknown) for integration tests. + required: false + default: "false" outputs: node-version: @@ -104,3 +108,18 @@ runs: --build-arg NODE_VERSION=${{ steps.node-version.outputs.node-version }} \ -t test-nextjs:latest \ crates/integration-tests/fixtures/frameworks/nextjs/ + + - name: Add wasm32-unknown-unknown target for Cloudflare build + if: ${{ inputs.build-cloudflare == 'true' }} + shell: bash + run: rustup target add wasm32-unknown-unknown + + - name: Build Cloudflare Workers bundle + if: ${{ inputs.build-cloudflare == 'true' }} + shell: bash + env: + TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} + TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret + TRUSTED_SERVER__SYNTHETIC__SECRET_KEY: integration-test-secret-key + TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" + run: bash crates/trusted-server-adapter-cloudflare/build.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2c570ef3..ae13cb91 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -16,6 +16,7 @@ env: WASM_ARTIFACT_PATH: /tmp/integration-test-artifacts/wasm/trusted-server-adapter-fastly.wasm AXUM_ARTIFACT_PATH: /tmp/integration-test-artifacts/axum/trusted-server-axum DOCKER_ARTIFACT_PATH: /tmp/integration-test-artifacts/docker/test-images.tar + CF_BUILD_ARTIFACT_PATH: /tmp/integration-test-artifacts/cloudflare/build jobs: prepare-artifacts: @@ -30,12 +31,14 @@ jobs: with: origin-port: ${{ env.ORIGIN_PORT }} install-viceroy: "false" + build-cloudflare: "true" - name: Package integration test artifacts run: | - mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" "$(dirname "$AXUM_ARTIFACT_PATH")" "$(dirname "$DOCKER_ARTIFACT_PATH")" + mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" "$(dirname "$AXUM_ARTIFACT_PATH")" "$(dirname "$DOCKER_ARTIFACT_PATH")" "$CF_BUILD_ARTIFACT_PATH" cp target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm "$WASM_ARTIFACT_PATH" cp target/debug/trusted-server-axum "$AXUM_ARTIFACT_PATH" + cp -r crates/trusted-server-adapter-cloudflare/build/. "$CF_BUILD_ARTIFACT_PATH/" docker save \ --output "$DOCKER_ARTIFACT_PATH" \ test-wordpress:latest test-nextjs:latest @@ -51,7 +54,7 @@ jobs: name: integration tests needs: prepare-artifacts runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -63,6 +66,7 @@ jobs: check-dependency-versions: "false" install-viceroy: "true" build-wasm: "false" + build-axum: "false" build-test-images: "false" - name: Download integration test artifacts @@ -74,18 +78,34 @@ jobs: - name: Make binaries executable run: chmod +x "$AXUM_ARTIFACT_PATH" + - name: Restore Cloudflare Workers bundle + run: | + mkdir -p crates/trusted-server-adapter-cloudflare/build + cp -r "$CF_BUILD_ARTIFACT_PATH/." crates/trusted-server-adapter-cloudflare/build/ + - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.shared-setup.outputs.node-version }} + + - name: Install wrangler + run: npm install -g wrangler + - name: Run integration tests run: >- cargo test --manifest-path crates/integration-tests/Cargo.toml --target x86_64-unknown-linux-gnu - -- --include-ignored --skip test_wordpress_fastly --skip test_nextjs_fastly --test-threads=1 + -- --include-ignored + --skip test_wordpress_fastly --skip test_nextjs_fastly + --test-threads=1 env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} AXUM_BINARY_PATH: ${{ env.AXUM_ARTIFACT_PATH }} + CLOUDFLARE_WRANGLER_DIR: ${{ github.workspace }}/crates/trusted-server-adapter-cloudflare INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} RUST_LOG: info diff --git a/crates/integration-tests/tests/environments/cloudflare.rs b/crates/integration-tests/tests/environments/cloudflare.rs index d7ef2535..e70a3642 100644 --- a/crates/integration-tests/tests/environments/cloudflare.rs +++ b/crates/integration-tests/tests/environments/cloudflare.rs @@ -1,39 +1,109 @@ -use crate::common::runtime::{RuntimeEnvironment, RuntimeProcess, TestError, TestResult}; -use error_stack::Report; -use std::path::Path; +use crate::common::runtime::{RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult}; +use error_stack::ResultExt as _; +use std::io::{BufRead as _, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; -/// Cloudflare Workers runtime environment. +/// Cloudflare Workers runtime via `wrangler dev`. /// -/// Not runnable in the current integration test harness — Cloudflare Workers -/// requires a Wrangler dev server (`wrangler dev`) which is not automated here. -/// -/// All integration tests for this environment are marked `#[ignore]`. To run -/// them locally, start Wrangler first: +/// In CI the bundle is pre-built and restored from artifacts; wrangler is +/// installed in the job. Locally, build the bundle first: /// /// ```sh -/// # Build the WASM binary -/// cargo build -p trusted-server-adapter-cloudflare --release --target wasm32-unknown-unknown --features cloudflare -/// -/// # Start the dev server (in crates/trusted-server-adapter-cloudflare/) -/// wrangler dev +/// cd crates/trusted-server-adapter-cloudflare && bash build.sh /// ``` /// -/// Then run with `-- --ignored test_wordpress_cloudflare`. +/// Then run the ignored tests with `-- --ignored test_wordpress_cloudflare`. +/// +/// Set `CLOUDFLARE_WRANGLER_DIR` to override the default crate root path. pub struct CloudflareWorkers; +/// Port wrangler dev binds to. Matches the Axum port; both run sequentially +/// under `--test-threads=1` so the port is never double-allocated. +const CLOUDFLARE_PORT: u16 = 8787; + impl RuntimeEnvironment for CloudflareWorkers { fn id(&self) -> &'static str { "cloudflare" } fn spawn(&self, _wasm_path: &Path) -> TestResult { - Err(Report::new(TestError::RuntimeSpawn).attach( - "Cloudflare Workers integration tests require a running `wrangler dev` instance \ - and are not automated in the CI harness. Run with --ignored to skip.", - )) + let wrangler_dir = self.wrangler_dir(); + let config = if std::env::var("CI").is_ok() { + "wrangler.ci.toml" + } else { + "wrangler.toml" + }; + + let mut child = Command::new("wrangler") + .args([ + "dev", + "--config", + config, + "--port", + &CLOUDFLARE_PORT.to_string(), + "--ip", + "127.0.0.1", + ]) + .current_dir(&wrangler_dir) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "Failed to spawn `wrangler dev` in {}. \ + Ensure wrangler is installed (`npm install -g wrangler`) \ + and the bundle is pre-built (`bash build.sh` in that directory).", + wrangler_dir.display() + ))?; + + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if !line.is_empty() { + log::debug!("cloudflare: {line}"); + } + } + }); + } + + let handle = CloudflareHandle { child }; + let base_url = format!("http://127.0.0.1:{CLOUDFLARE_PORT}"); + + super::wait_for_ready(&base_url, self.health_check_path(), true)?; + + Ok(RuntimeProcess { inner: Box::new(handle), base_url }) } fn health_check_path(&self) -> &str { "/.well-known/trusted-server.json" } } + +impl CloudflareWorkers { + /// Resolve the Cloudflare adapter crate root. + /// + /// Respects `CLOUDFLARE_WRANGLER_DIR` for CI overrides; falls back to + /// the path relative to this crate's `CARGO_MANIFEST_DIR`. + fn wrangler_dir(&self) -> PathBuf { + if let Ok(dir) = std::env::var("CLOUDFLARE_WRANGLER_DIR") { + return PathBuf::from(dir); + } + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/trusted-server-adapter-cloudflare") + } +} + +struct CloudflareHandle { + child: Child, +} + +impl RuntimeProcessHandle for CloudflareHandle {} + +impl Drop for CloudflareHandle { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml new file mode 100644 index 00000000..d68be33a --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -0,0 +1,14 @@ +name = "trusted-server" +main = "build/index.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] +# No [build] section — bundle is pre-built in CI; wrangler dev must not rebuild. + +[[kv_namespaces]] +binding = "TRUSTED_SERVER_KV" +id = "ci-local-kv" + +[vars] +# Settings are baked into the WASM binary at build time; this JSON only needs +# to satisfy any runtime config-store lookups (e.g. request-signing keys). +TRUSTED_SERVER_CONFIG = "{}" From 04dfc4e2bd11d91cbdc092d325cd425c6c315e54 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 20 Apr 2026 20:57:53 +0530 Subject: [PATCH 8/9] Resolve PR review findings in Cloudflare adapter - Add GeoInfo happy-path test: build_geo_lookup_returns_some_with_populated_country verifies country, city, continent, latitude, and longitude are correctly populated when Cloudflare headers are present - Simplify CloudflareSecretStoreAdapter::get_bytes: collapse brittle JsError string-matching guards into a single error arm with contextual message - Document sequential fan-out latency in CloudflareHttpClient: explain sum(DSP_i) vs max(DSP_i) implication and why true parallelism is blocked by the ?Send bound on PlatformHttpClient - Fix stale wrangler.toml comment: update to reflect ConfigStoreHandleAdapter wiring rather than the now-fallback NoopConfigStore - Extend CI triggers to feature branches: format.yml and test.yml now run fmt/clippy/tests on PRs targeting feature/** so stacking PRs are gated - Fix fmt violations caught during pre-review verification: platform.rs two-line Err wrapping and plan doc Prettier formatting --- .github/workflows/format.yml | 2 +- .github/workflows/test.yml | 2 +- .../src/platform.rs | 63 ++++++++++++++----- .../wrangler.toml | 6 +- ...-04-14-edgezero-pr15-remove-fastly-core.md | 42 +++++++++---- 5 files changed, 83 insertions(+), 32 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b6aba137..dcbd1d1e 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, "feature/**"] permissions: contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae4ce6e0..bd8c3848 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, "feature/**"] jobs: test-rust: diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 48b640b8..6b9aa57f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -99,13 +99,11 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore) - .attach("config store writes are not supported")) + Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) } fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore) - .attach("config store writes are not supported")) + Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) } } @@ -174,8 +172,19 @@ struct CloudflarePendingResponse { /// [`worker::Fetch`]-backed HTTP client for the Cloudflare Workers runtime. /// -/// `send_async` eagerly awaits (sequential fan-out) — acceptable for Workers -/// since Cloudflare's own runtime handles true parallelism at the event level. +/// # Sequential fan-out and auction latency +/// +/// `send_async` eagerly awaits each request before returning, so parallel +/// fan-out (e.g. auction DSP calls) becomes sequential: total latency is +/// `sum(DSP_i)` rather than `max(DSP_i)`. With a 300 ms auction budget and +/// three DSPs averaging 80 ms each, all three return in time; with five DSPs +/// the last two may be cut off by the orchestrator's remaining-budget check. +/// +/// Cloudflare Workers does support concurrent `fetch` calls via `Promise.all`, +/// but the `?Send` bound on `PlatformHttpClient` prevents using `join!` across +/// requests here. A future revision could implement true fan-out by spawning all +/// futures inside `select` before polling, at the cost of a more complex +/// implementation. /// /// Individual fetch calls have no explicit timeout. The Workers runtime enforces /// a global CPU time limit per invocation (default 30 s wall-clock on paid plans) @@ -349,19 +358,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { _store_name: &StoreName, key: &str, ) -> Result, Report> { - use worker::Error as WorkerError; match self.env.secret(key) { Ok(secret) => Ok(secret.to_string().into_bytes()), - Err(WorkerError::BindingError(_)) => Err(Report::new(PlatformError::SecretStore) - .attach(format!("secret binding not found: {key}"))), - Err(WorkerError::JsError(msg)) - if msg.contains("does not contain binding") || msg.contains("is undefined") => - { - Err(Report::new(PlatformError::SecretStore) - .attach(format!("secret not found: {key}"))) - } Err(err) => Err(Report::new(PlatformError::SecretStore) - .attach(format!("secret lookup failed: {err}"))), + .attach(format!("secret lookup failed for key `{key}`: {err}"))), } } @@ -604,4 +604,35 @@ mod tests { "should return None when no country header" ); } + + #[test] + fn build_geo_lookup_returns_some_with_populated_country() { + let req = request_builder() + .method("GET") + .uri("https://example.com/") + .header("cf-ipcountry", HeaderValue::from_static("US")) + .header("cf-ipcity", HeaderValue::from_static("New York")) + .header("cf-ipcontinent", HeaderValue::from_static("NA")) + .header("cf-iplatitude", HeaderValue::from_static("40.71")) + .header("cf-iplongitude", HeaderValue::from_static("-74.01")) + .body(edgezero_core::body::Body::empty()) + .unwrap(); + let ctx = RequestContext::new(req, PathParams::default()); + let geo = build_geo(&ctx); + let info = geo + .lookup(None) + .unwrap() + .expect("should return GeoInfo when country is set"); + assert_eq!(info.country, "US", "should populate country"); + assert_eq!(info.city, "New York", "should populate city"); + assert_eq!(info.continent, "NA", "should populate continent"); + assert!( + (info.latitude - 40.71).abs() < 0.01, + "should populate latitude" + ); + assert!( + (info.longitude - (-74.01)).abs() < 0.01, + "should populate longitude" + ); + } } diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index b5982c45..060d4fd9 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -12,7 +12,7 @@ id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [vars] # TRUSTED_SERVER_CONFIG is consumed by the edgezero layer as a JSON-encoded env -# var for local dev. On a deployed Worker this will be replaced by a proper KV -# namespace or config store binding once edgezero config-store wiring is added -# (tracked: NoopConfigStore in platform.rs). +# var binding. At runtime it is bridged to PlatformConfigStore via +# ConfigStoreHandleAdapter — replace the placeholder values with your publisher +# settings before deploying. TRUSTED_SERVER_CONFIG = '{"publisher.domain":"your-publisher.com"}' diff --git a/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md b/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md index 683d3db6..f4ebae47 100644 --- a/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md +++ b/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md @@ -16,23 +16,24 @@ Read these before starting — do not guess: -| What to read | Path | Why | -|---|---|---| -| Core Cargo.toml | `crates/trusted-server-core/Cargo.toml` | Exact dep names to remove | -| Core lib.rs | `crates/trusted-server-core/src/lib.rs` | Module declarations to remove | -| Adapter main.rs | `crates/trusted-server-adapter-fastly/src/main.rs` | `compat::` call sites (lines 12, 159, 169, 182) | -| Core compat.rs | `crates/trusted-server-core/src/compat.rs` | Functions to port | -| Core geo.rs | `crates/trusted-server-core/src/geo.rs` | `geo_from_fastly` impl (lines 25–35) | -| Core backend.rs | `crates/trusted-server-core/src/backend.rs` | Entire module to port | -| Adapter platform.rs | `crates/trusted-server-adapter-fastly/src/platform.rs` | Import lines to update (17, 18, 362) | -| Adapter management_api.rs | `crates/trusted-server-adapter-fastly/src/management_api.rs` | `BackendConfig` import (line 55) | -| Core consent/kv.rs | `crates/trusted-server-core/src/consent/kv.rs` | Verify any `fastly::kv_store` usage | +| What to read | Path | Why | +| ------------------------- | ------------------------------------------------------------ | ----------------------------------------------- | +| Core Cargo.toml | `crates/trusted-server-core/Cargo.toml` | Exact dep names to remove | +| Core lib.rs | `crates/trusted-server-core/src/lib.rs` | Module declarations to remove | +| Adapter main.rs | `crates/trusted-server-adapter-fastly/src/main.rs` | `compat::` call sites (lines 12, 159, 169, 182) | +| Core compat.rs | `crates/trusted-server-core/src/compat.rs` | Functions to port | +| Core geo.rs | `crates/trusted-server-core/src/geo.rs` | `geo_from_fastly` impl (lines 25–35) | +| Core backend.rs | `crates/trusted-server-core/src/backend.rs` | Entire module to port | +| Adapter platform.rs | `crates/trusted-server-adapter-fastly/src/platform.rs` | Import lines to update (17, 18, 362) | +| Adapter management_api.rs | `crates/trusted-server-adapter-fastly/src/management_api.rs` | `BackendConfig` import (line 55) | +| Core consent/kv.rs | `crates/trusted-server-core/src/consent/kv.rs` | Verify any `fastly::kv_store` usage | --- ## File Map ### Files to **delete** from `crates/trusted-server-core/src/` + - `compat.rs` — Fastly conversion scaffolding, scheduled for deletion in PR 15 - `backend.rs` — Fastly-coupled backend builder, moved to adapter - `storage/config_store.rs` — Legacy `FastlyConfigStore` (call sites migrated to platform traits) @@ -40,19 +41,23 @@ Read these before starting — do not guess: - `storage/mod.rs` — Empty after above deletions ### Files to **modify** in `crates/trusted-server-core/src/` + - `lib.rs` — Remove `pub mod compat;`, `pub mod backend;`, `pub mod storage;` - `geo.rs` — Remove `use fastly::geo::Geo;` and `pub fn geo_from_fastly` ### Files to **create** in `crates/trusted-server-adapter-fastly/src/` + - `compat.rs` — The 3 conversion functions that adapter's `main.rs` needs - `backend.rs` — Full `BackendConfig` moved from core ### Files to **modify** in `crates/trusted-server-adapter-fastly/src/` + - `main.rs` — Add `mod compat;`, update import from `trusted_server_core::compat` to `crate::compat` - `platform.rs` — Remove `use trusted_server_core::geo::geo_from_fastly;`, add inline private function; remove `use trusted_server_core::backend::BackendConfig;`, add `use crate::backend::BackendConfig;` - `management_api.rs` — Update `use trusted_server_core::backend::BackendConfig` → `use crate::backend::BackendConfig` ### Files to **modify** (Cargo.toml) + - `crates/trusted-server-core/Cargo.toml` — Remove `fastly`, move `tokio` → `[dev-dependencies]` --- @@ -89,6 +94,7 @@ Expected: `Finished` with no errors. **Context:** Adapter's `main.rs` uses `trusted_server_core::compat` for 3 functions in `legacy_main()`: `sanitize_fastly_forwarded_headers`, `from_fastly_request`, and `to_fastly_response`. All three deal with `fastly::Request` / `fastly::Response` — they belong in the adapter. The remaining ~8 functions in core's `compat.rs` are unused by the adapter and can be dropped entirely. **Files:** + - Create: `crates/trusted-server-adapter-fastly/src/compat.rs` - Modify: `crates/trusted-server-adapter-fastly/src/main.rs` - Delete: `crates/trusted-server-core/src/compat.rs` @@ -97,6 +103,7 @@ Expected: `Finished` with no errors. - [ ] **Step 2.1: Read core's `compat.rs` fully** Read `crates/trusted-server-core/src/compat.rs` lines 1–560. You need the exact implementations of: + - `sanitize_fastly_forwarded_headers` — strips spoofable forwarded headers from a `fastly::Request` - `from_fastly_request` — converts owned `fastly::Request` → `http::Request` - `to_fastly_response` — converts `http::Response` → `fastly::Response` @@ -186,6 +193,7 @@ git commit -m "Move compat conversion fns to adapter, delete core compat.rs" **Context:** Core's `geo.rs` imports `fastly::geo::Geo` solely for `geo_from_fastly`. The adapter's `platform.rs` (line 18) imports this function from core and calls it at line 362. Moving it inline into `platform.rs` as a `pub(crate)` or private function is the minimal change — no new file required. **Files:** + - Modify: `crates/trusted-server-core/src/geo.rs` - Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` @@ -232,6 +240,7 @@ Then remove the import line `use trusted_server_core::geo::geo_from_fastly;` (li - [ ] **Step 3.3: Remove `geo_from_fastly` and the fastly import from core's `geo.rs`** In `crates/trusted-server-core/src/geo.rs`: + - Remove: `use fastly::geo::Geo;` - Remove: the entire `pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { ... }` function and its doc comment @@ -260,6 +269,7 @@ git commit -m "Move geo_from_fastly from core to adapter platform" **Context:** Core's `backend.rs` exists solely to create dynamic Fastly backends (`fastly::backend::Backend`). Both `platform.rs` (line 17) and `management_api.rs` (line 55) in the adapter import `BackendConfig` from core. Moving the entire module to the adapter is a clean cut with minimal ripple. **Files:** + - Create: `crates/trusted-server-adapter-fastly/src/backend.rs` - Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (add `mod backend;`) - Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` @@ -291,6 +301,7 @@ Add `mod backend;` to `crates/trusted-server-adapter-fastly/src/main.rs`. - [ ] **Step 4.4: Update imports in `platform.rs` and `management_api.rs`** In `crates/trusted-server-adapter-fastly/src/platform.rs` (line 17): + ```rust // Remove: use trusted_server_core::backend::BackendConfig; @@ -299,6 +310,7 @@ use crate::backend::BackendConfig; ``` In `crates/trusted-server-adapter-fastly/src/management_api.rs` (line 55): + ```rust // Remove: use trusted_server_core::backend::BackendConfig; @@ -349,6 +361,7 @@ git commit -m "Move BackendConfig from core to adapter backend module" **Context:** `crates/trusted-server-core/src/storage/` exports `FastlyConfigStore` and `FastlySecretStore`. The adapter does not import either — it uses the platform traits (`PlatformConfigStore`, `PlatformSecretStore`) directly. Core's `platform/mod.rs` is also trait-only and has no dependency on these legacy types. The storage doc comment confirms: "will be removed once all call sites have migrated to platform traits." **Files:** + - Delete: `crates/trusted-server-core/src/storage/config_store.rs` - Delete: `crates/trusted-server-core/src/storage/secret_store.rs` - Delete: `crates/trusted-server-core/src/storage/mod.rs` @@ -399,6 +412,7 @@ git commit -m "Delete legacy FastlyConfigStore and FastlySecretStore from core" **Context:** The initial audit flagged possible `fastly::kv_store::KVStore` usage at line 230 of `consent/kv.rs`. The top of the file (lines 1–50) shows no fastly imports — the reference may be via fully-qualified path or may have been a hallucination. Verify before removing `fastly` from Cargo.toml. **Files:** + - Inspect: `crates/trusted-server-core/src/consent/kv.rs` - Possibly modify: same file @@ -413,6 +427,7 @@ grep -n "fastly" crates/trusted-server-core/src/consent/kv.rs - [ ] **Step 6.2b (if fastly:: appears): Investigate and move** Read the lines around each match. The KV store usage in consent likely goes through the `PlatformKvStore` trait (from `edgezero-core`). If raw `fastly::kv_store::KVStore` calls exist: + - Understand what function uses it (likely `open_store` or `fingerprint_unchanged`) - Move that function to adapter's consent integration or abstract via a trait closure / callback passed in from the adapter - The goal is zero `fastly::` references in core @@ -437,6 +452,7 @@ git commit -m "Remove fastly::kv_store usage from core consent module" **Context:** `tokio` appears in `[dependencies]` (line 45 of core's `Cargo.toml`). The audit found zero tokio usage in production code — all 30 uses are `#[tokio::test]` attributes in test modules. Moving it to `[dev-dependencies]` removes it from the production dependency graph for wasm builds. **Files:** + - Modify: `crates/trusted-server-core/Cargo.toml` - [ ] **Step 7.1: Confirm no production tokio usage** @@ -454,11 +470,13 @@ Expected: no results. If any appear, investigate and refactor before proceeding. In `crates/trusted-server-core/Cargo.toml`: Remove from `[dependencies]`: + ```toml tokio = { workspace = true } ``` Add to `[dev-dependencies]` (alongside `tokio-test`): + ```toml tokio = { workspace = true } ``` @@ -493,6 +511,7 @@ git commit -m "Move tokio to dev-dependencies in core (test-only usage)" **Context:** After Tasks 2–6, core should have zero `fastly::` references. Now remove the dependency. **Files:** + - Modify: `crates/trusted-server-core/Cargo.toml` - [ ] **Step 8.1: Confirm zero remaining fastly references in core** @@ -514,6 +533,7 @@ If `log-fastly` appears, remove it alongside `fastly` in the next step. - [ ] **Step 8.2: Remove `fastly` (and `log-fastly` if present) from core's `Cargo.toml`** In `crates/trusted-server-core/Cargo.toml`, remove: + ```toml fastly = { workspace = true } # Also remove if present: From e761628d56c9bfa873f47c1e24c514301b6f290d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 20 Apr 2026 21:26:25 +0530 Subject: [PATCH 9/9] Exclude Cloudflare adapter from wasm32-wasip1 test job The adapter's tokio dev-dependency uses rt-multi-thread which is not supported on wasm32. It is already tested in the dedicated test-cloudflare job; exclude it from the workspace wasm test to avoid the compile error. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd8c3848..7ba8652f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: run: cargo install --git https://github.com/fastly/Viceroy viceroy - name: Run tests - run: cargo test --workspace --exclude trusted-server-adapter-axum --target wasm32-wasip1 + run: cargo test --workspace --exclude trusted-server-adapter-axum --exclude trusted-server-adapter-cloudflare --target wasm32-wasip1 test-axum: name: cargo test (axum native)