diff --git a/.cargo/config.toml b/.cargo/config.toml index 1918f7d35..a9a4de79e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -33,12 +33,14 @@ test-axum = ["test", "-p", "trusted-server-adapter-axum"] # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); -# tests and clippy run on the native host. +# tests run on the native host; clippy covers both native test code and the +# production WASM feature. build-cloudflare = ["build", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] check-cloudflare = ["check", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] # No --all-features: the `cloudflare` feature has a compile_error! guard on -# non-wasm32 targets. WASM-feature coverage comes from `cargo check-cloudflare`. +# non-wasm32 targets. clippy-cloudflare = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--all-targets", "--", "-D", "warnings"] +clippy-cloudflare-wasm = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare", "--lib", "--", "-D", "warnings"] test-cloudflare = ["test", "-p", "trusted-server-adapter-cloudflare"] # --- Spin adapter (native host tests + wasm32-wasip1 target) --- diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index a73116ffe..4893a955d 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -25,7 +25,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} - target: wasm32-wasip1 + target: wasm32-wasip1,wasm32-unknown-unknown components: "clippy, rustfmt" cache-shared-key: cargo-${{ runner.os }} @@ -41,6 +41,9 @@ jobs: - name: Run cargo clippy (Cloudflare — native) run: cargo clippy-cloudflare + - name: Run cargo clippy (Cloudflare — wasm32-unknown-unknown) + run: cargo clippy-cloudflare-wasm + - name: Run cargo clippy (Spin — native) run: cargo clippy-spin-native diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 370b22e76..ec85b96ac 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -111,11 +111,11 @@ jobs: AXUM_BINARY_PATH: ${{ env.AXUM_ARTIFACT_PATH }} CLOUDFLARE_WRANGLER_DIR: ${{ github.workspace }}/crates/trusted-server-adapter-cloudflare INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy-legacy.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml RUST_LOG: info - integration-tests-edgezero: - name: integration tests (EdgeZero entry point) + integration-tests-fastly-ec: + name: integration tests (Fastly EC lifecycle) needs: prepare-artifacts runs-on: ubuntu-latest timeout-minutes: 15 @@ -137,14 +137,9 @@ jobs: name: integration-test-artifacts path: ${{ env.ARTIFACTS_DIR }} - # Exercises the EdgeZero entry point against the same WASM binary by - # pointing Viceroy at a config store with `edgezero_enabled = "true"`. - # Scoped to the container-free EC lifecycle suite (minimal TCP origin), a - # focused parity subset covering Fastly request conversion, config-store - # dispatch, publisher fallback proxying, and end-to-end EC/API wiring on - # the EdgeZero path. The legacy `integration-tests` job above still covers - # the full framework matrix. - - name: Run EdgeZero EC lifecycle tests + # Focused container-free EC lifecycle coverage using the same post-cutover + # Viceroy config as the full integration matrix. + - name: Run Fastly EC lifecycle tests run: >- cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml @@ -154,7 +149,7 @@ jobs: env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy-edgezero.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml RUST_LOG: info browser-tests: @@ -201,7 +196,7 @@ jobs: env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy-legacy.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml TEST_FRAMEWORK: nextjs PLAYWRIGHT_HTML_REPORT: playwright-report-nextjs run: npx playwright test @@ -220,7 +215,7 @@ jobs: env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy-legacy.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml TEST_FRAMEWORK: wordpress PLAYWRIGHT_HTML_REPORT: playwright-report-wordpress run: npx playwright test diff --git a/CLAUDE.md b/CLAUDE.md index 2c902a7c4..8ccd5df40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,7 @@ cargo fmt --all -- --check cargo clippy-fastly cargo clippy-axum cargo clippy-cloudflare +cargo clippy-cloudflare-wasm cargo clippy-spin-native cargo clippy-spin-wasm @@ -332,7 +333,7 @@ IntegrationRegistration::builder(ID) Every PR must pass: 1. `cargo fmt --all -- --check` -2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-spin-native && cargo clippy-spin-wasm` +2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm` 3. `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin` 4. `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity` 5. JS build and test (`cd crates/trusted-server-js/lib && npx vitest run`) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 0319ab1c9..08da65160 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, Method, Request, Response, header}; +use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; @@ -24,8 +24,7 @@ use trusted_server_core::publisher::{ PublisherResponse, buffer_publisher_response, 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, + handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; @@ -194,6 +193,20 @@ pub(crate) fn http_error(report: &Report) -> Response { response } +fn admin_key_management_not_supported() -> Response { + let body = edgezero_core::body::Body::from( + "Admin key management is not supported on Cloudflare Workers.\n\ + Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", + ); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::NOT_IMPLEMENTED; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + /// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` /// aliases on the Cloudflare adapter. /// @@ -383,18 +396,12 @@ fn build_router(state: &Arc) -> RouterService { // letting them fall through would forward the caller's // `Authorization` header and key-management payload to the origin, // leaking admin credentials. - .post( - "/_ts/admin/keys/rotate", - make_handler(Arc::clone(&state), |s, services, req| async move { - handle_rotate_key(&s.settings, &services, req) - }), - ) - .post( - "/_ts/admin/keys/deactivate", - make_handler(Arc::clone(&state), |s, services, req| async move { - handle_deactivate_key(&s.settings, &services, req) - }), - ) + .post("/_ts/admin/keys/rotate", |_ctx: RequestContext| async { + Ok::(admin_key_management_not_supported()) + }) + .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { + Ok::(admin_key_management_not_supported()) + }) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index a067f5774..2ce435b17 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -17,6 +17,12 @@ use worker::{Context, Env, Request, Response, Result, event}; #[cfg(target_arch = "wasm32")] #[event(fetch)] +/// Dispatches an incoming Cloudflare Worker fetch event. +/// +/// # Errors +/// +/// Returns a Workers runtime error when the fallback error response cannot be +/// constructed. pub async fn main(req: Request, env: Env, ctx: Context) -> Result { if let Ok(config) = env.var("TRUSTED_SERVER_CONFIG") { app::set_cloudflare_config_json(config.to_string()); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 190a91af9..a01c4d979 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -243,8 +243,7 @@ fn is_hop_by_hop_response_header(name: &str, connection_tokens: &[String]) -> bo "upgrade", ]; let lower = name.to_ascii_lowercase(); - HOP_BY_HOP.iter().any(|header| *header == lower) - || connection_tokens.iter().any(|token| *token == lower) + HOP_BY_HOP.iter().any(|header| *header == lower) || connection_tokens.contains(&lower) } #[cfg(target_arch = "wasm32")] @@ -339,13 +338,12 @@ impl CloudflareHttpClient { .ok() .flatten() .and_then(|v| v.trim().parse::().ok()) + && claimed_len > MAX_PLATFORM_RESPONSE_BODY_BYTES { - if claimed_len > MAX_PLATFORM_RESPONSE_BODY_BYTES { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "origin Content-Length {claimed_len} exceeds \ - {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte response body limit" - ))); - } + return Err(Report::new(PlatformError::HttpClient).attach(format!( + "origin Content-Length {claimed_len} exceeds \ + {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte response body limit" + ))); } let connection_tokens = response_connection_tokens(&resp); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 305559be8..df2781945 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -238,6 +238,32 @@ fn all_explicit_routes_are_registered() { // Basic-auth parity tests // --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_routes_return_501() { + for (path, body) in [ + ("/_ts/admin/keys/rotate", "{}"), + ( + "/_ts/admin/keys/deactivate", + r#"{"kid":"test-key","delete":false}"#, + ), + ] { + let req = request_builder() + .method("POST") + .uri(path) + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(body)) + .expect("should build request"); + let resp = route(test_router(), req).await; + + assert_eq!( + resp.status().as_u16(), + 501, + "{path} should report that Cloudflare key management is unsupported" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn admin_route_without_credentials_returns_401() { let router = test_router(); diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ee0e94185..04e584720 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1,6 +1,6 @@ //! Full `EdgeZero` application wiring for Trusted Server. //! -//! Registers all routes from the legacy [`crate::route_request`] into a +//! Registers all routes for Trusted Server into a //! [`RouterService`]. On successful startup, attaches [`FinalizeResponseMiddleware`] //! (outermost) and [`AuthMiddleware`] (inner). When startup fails, //! [`startup_error_router`] returns a bare router without middleware. @@ -65,10 +65,10 @@ //! run on these responses. Legacy ran EC finalization on its own auth //! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC //! cookies are issued to unauthenticated callers. -//! - **Publisher responses** are buffered (bounded by -//! `publisher.max_buffered_body_bytes`) instead of streamed to the client. -//! Asset responses are streamed straight to the client (see -//! [`dispatch_asset_fallback`]), matching legacy. +//! - **Publisher HTML responses with full-document post-processors** remain +//! buffered (bounded by `publisher.max_buffered_body_bytes`). Other +//! processable publisher responses and asset responses stream through to the +//! client. //! - **Router-level 405s** (unregistered verbs) skip EC finalization along //! with the middleware chain; the entry point still adds TS headers. //! @@ -115,7 +115,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy, }; use trusted_server_core::publisher::{ - buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, + handle_streaming_publisher_request, handle_tsjs_dynamic, OwnedProcessResponseParams, + PublisherResponse, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -148,6 +149,14 @@ pub(crate) struct AppState { pub(crate) default_kv_store: Arc, } +/// Processing state consumed by the Fastly entry point when it writes a +/// publisher response directly into the client stream. +pub(crate) struct PublisherStreamState { + pub(crate) params: OwnedProcessResponseParams, + pub(crate) settings: Arc, + pub(crate) registry: Arc, +} + /// Build the application state, loading settings and constructing all per-application components. /// /// # Errors @@ -193,11 +202,9 @@ fn warn_if_certificate_check_disabled(settings: &Settings) { /// Resolves per-request consent KV store services for routes that read consent data. /// /// When `settings.consent.consent_store` is configured and the named KV store cannot -/// be opened, returns `Err` so the caller can respond with 503 (fail-closed). This is -/// intentional hardening over the legacy `route_request` path, which builds -/// `runtime_services` with `UnavailableKvStore` and never opens the named consent -/// store, so it never fails closed — the `EdgeZero` path instead makes consent-dependent -/// routes unavailable rather than proceeding without consent. +/// be opened, returns `Err` so the caller can respond with 503 (fail-closed). This +/// ensures a misconfigured consent store makes consent-dependent routes unavailable +/// rather than proceeding without consent. /// /// # Errors /// @@ -733,18 +740,16 @@ async fn dispatch_fallback( // available — fail closed with 503 when it is configured but cannot // be opened, matching legacy behavior. match runtime_services_for_consent_route(&state.settings, services) { - Ok(publisher_services) => { - handle_publisher_request(&state.settings, &state.registry, &publisher_services, req) - .await - .and_then(|pub_response| { - buffer_publisher_response( - pub_response, - &method, - &state.settings, - &state.registry, - ) - }) - } + Ok(publisher_services) => handle_streaming_publisher_request( + &state.settings, + &state.registry, + &publisher_services, + req, + ) + .await + .map(|publisher_response| { + resolve_publisher_response(publisher_response, &method, state) + }), Err(e) => Err(e), } }; @@ -753,6 +758,43 @@ async fn dispatch_fallback( attach_dispatch_extensions(response, ec, effects) } +fn resolve_publisher_response( + publisher_response: PublisherResponse, + method: &Method, + state: &AppState, +) -> Response { + match publisher_response { + PublisherResponse::Buffered(response) => response, + PublisherResponse::Stream { + mut response, + body, + params, + } => { + if response_carries_body(method, response.status()) { + *response.body_mut() = body; + response + .extensions_mut() + .insert(Arc::new(PublisherStreamState { + params, + settings: Arc::clone(&state.settings), + registry: Arc::clone(&state.registry), + })); + } + response + } + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + response + } + } +} + +fn response_carries_body(method: &Method, status: StatusCode) -> bool { + *method != Method::HEAD + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED +} + /// Returns `true` when an asset response should carry a (streamed) body. /// /// `HEAD` responses and bodiless statuses (204, 304) advertise the origin @@ -839,11 +881,7 @@ fn attach_request_filter_effects(response: &mut Response, effects: &RequestFilte // Error helper // --------------------------------------------------------------------------- -/// Convert a [`Report`] into an HTTP [`Response`], -/// mirroring [`crate::http_error_response`] exactly. -/// -/// The near-identical function in `main.rs` is intentional: the legacy path -/// uses fastly HTTP types while this path uses `edgezero_core` types. +/// Converts 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); @@ -1137,13 +1175,11 @@ mod tests { build_state_from_settings, startup_error_router, AppState, NamedRouteHandler, TrustedServerApp, NAMED_ROUTES, }; - use crate::route_tests::{ - test_runtime_services_with_secret_http_client_and_geo, us_california_geo, FixedBackend, - FixedGeo, NoopSecretStore, StreamingRecordingHttpClient, - }; + use bytes::Bytes; use edgezero_core::body::Body; use edgezero_core::http::{header, request_builder, Method, Response, StatusCode}; + use edgezero_core::key_value_store::NoopKvStore; use edgezero_core::router::RouterService; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Mutex; @@ -1158,7 +1194,11 @@ mod tests { HeaderMutation, IntegrationRegistry, IntegrationRequestFilter, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, }; - use trusted_server_core::platform::{ClientInfo, PlatformHttpClient}; + use trusted_server_core::platform::{ + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, PlatformHttpClient, + PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, PlatformResponse, + PlatformSelectResult, RuntimeServices, + }; use trusted_server_core::settings::Settings; fn settings_with_missing_consent_store() -> Settings { @@ -1221,6 +1261,70 @@ mod tests { block_on(router.oneshot(request)).expect("should route request") } + struct FixedBackend; + + impl PlatformBackend for FixedBackend { + fn predict_name( + &self, + spec: &PlatformBackendSpec, + ) -> Result> { + Ok(format!("{}-{}", spec.scheme, spec.host)) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } + } + + struct StreamingHttpClient; + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for StreamingHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + assert!( + request.stream_response, + "streaming Fastly routes should request an origin response stream" + ); + let response = edgezero_core::http::response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html") + .body(Body::stream(futures::stream::iter(vec![ + Bytes::from_static(b"streamed-asset"), + ]))) + .map_err(|_| Report::new(PlatformError::HttpClient))?; + Ok(PlatformResponse::new(response)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + fn streaming_runtime_services() -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) + .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) + .kv_store(Arc::new(NoopKvStore) as Arc) + .backend(Arc::new(FixedBackend)) + .http_client(Arc::new(StreamingHttpClient)) + .geo(Arc::new(crate::platform::FastlyPlatformGeo)) + .client_info(ClientInfo::default()) + .build() + } + fn test_settings() -> Settings { Settings::from_toml( r#" @@ -1871,9 +1975,9 @@ mod tests { #[test] fn dispatch_head_on_named_get_route_falls_through_to_publisher_fallback() { // Regression guard: HEAD /first-party/proxy must reach the publisher - // fallback, not return a router-level 405. Legacy route_request proxies - // every (method, path) combination not matched by a specific arm through - // to the publisher origin. + // fallback, not return a router-level 405. The EdgeZero dispatch path + // proxies every (method, path) combination not matched by a specific + // arm through to the publisher origin. // // Without a live backend the publisher proxy errors (502/503), but the // important invariant is that the status is NOT 405. @@ -2085,16 +2189,8 @@ mod tests { .expect("should parse asset-route settings"); let state = build_state_from_settings(settings).expect("should build state"); - let fastly_req = fastly::Request::get("https://test-publisher.com/.images/logo.png"); - let http_client = Arc::new(StreamingRecordingHttpClient::new()); - let services = test_runtime_services_with_secret_http_client_and_geo( - &fastly_req, - Arc::new(FixedBackend), - Arc::new(NoopSecretStore), - Arc::clone(&http_client) as Arc, - Arc::new(FixedGeo(us_california_geo())), - ); - let req = crate::compat::from_fastly_request(fastly_req); + let services = streaming_runtime_services(); + let req = empty_request(Method::GET, "/.images/logo.png"); let asset_route = state .settings .asset_route_for_path("/.images/logo.png") @@ -2120,6 +2216,47 @@ mod tests { ); } + #[test] + fn dispatch_publisher_fallback_preserves_origin_stream() { + let state = + build_state_from_settings(test_settings()).expect("should build publisher test state"); + let services = streaming_runtime_services(); + let req = request_builder() + .method(Method::GET) + .uri("https://test-publisher.com/") + .header(header::HOST, "test-publisher.com") + .body(Body::empty()) + .expect("should build publisher request"); + + let response = block_on(super::dispatch_fallback(&state, &services, req)); + + assert_eq!( + response.status(), + StatusCode::OK, + "should preserve the streaming origin status" + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/html"), + "should preserve the processable origin content type" + ); + assert!( + matches!(response.body(), Body::Stream(_)), + "Fastly publisher dispatch must preserve the origin stream: {:?}", + response.body() + ); + assert!( + response + .extensions() + .get::>() + .is_some(), + "publisher streams should carry processing state for the entry point" + ); + } + #[test] fn dispatch_runs_request_filter_and_threads_response_effects() { // Regression guard for the EdgeZero request-filter bypass: the publisher diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index 8b1484489..b46e8cc9b 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -1,46 +1,9 @@ //! Compatibility bridge between `fastly` SDK types and `http` crate types. -//! -//! Contains only the functions used by the legacy `main()` entry point. -//! Relocated from `trusted-server-core` as part of removing all `fastly` crate -//! imports from the core library. use edgezero_core::body::Body as EdgeBody; -use edgezero_core::http::{Request as HttpRequest, RequestBuilder, Response as HttpResponse, Uri}; +use edgezero_core::http::Response as HttpResponse; use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; -fn build_http_request(req: &fastly::Request, body: EdgeBody) -> HttpRequest { - // Does not panic in practice: a URL that Fastly accepts but `http::Uri` - // rejects degrades to "/" instead of aborting the Wasm instance. - let uri: Uri = req.get_url_str().parse().unwrap_or_else(|_| { - log::warn!( - "failed to parse fastly request URL '{}'; falling back to '/'", - req.get_url_str() - ); - Uri::from_static("/") - }); - - let mut builder: RequestBuilder = edgezero_core::http::request_builder() - .method(req.get_method().clone()) - .uri(uri); - - for (name, value) in req.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - builder - .body(body) - .expect("should build http request from fastly request") -} - -/// Convert an owned `fastly::Request` into an [`HttpRequest`]. -/// -/// URLs that Fastly accepts but `http::Uri` rejects fall back to `/` with a -/// warning instead of panicking, preserving availability on the legacy path. -pub(crate) fn from_fastly_request(mut req: fastly::Request) -> HttpRequest { - let body = EdgeBody::from(req.take_body_bytes()); - build_http_request(&req, body) -} - /// Convert an [`HttpResponse`] into a `fastly::Response`. pub(crate) fn to_fastly_response(resp: HttpResponse) -> fastly::Response { let (parts, body) = resp.into_parts(); @@ -134,23 +97,6 @@ mod tests { assert!(req.get_header("host").is_some(), "should preserve host"); } - #[test] - fn from_fastly_request_falls_back_to_root_on_unparseable_url() { - // `url::Url` (used by fastly) has no length limit, but `http::Uri` - // rejects URIs longer than 65534 bytes — an accepted-by-Fastly, - // rejected-by-Uri divergence the fallback guards against. - let long_url = format!("https://example.com/{}", "a".repeat(70_000)); - let req = fastly::Request::get(long_url); - - let http_req = from_fastly_request(req); - - assert_eq!( - http_req.uri(), - &Uri::from_static("/"), - "should fall back to '/' when the fastly URL cannot be parsed as an http::Uri" - ); - } - #[test] fn to_fastly_response_with_streaming_body_produces_empty_body() { use edgezero_core::http::StatusCode; diff --git a/crates/trusted-server-adapter-fastly/src/error.rs b/crates/trusted-server-adapter-fastly/src/error.rs deleted file mode 100644 index 560a2e181..000000000 --- a/crates/trusted-server-adapter-fastly/src/error.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Error conversion utilities for Fastly. -//! -//! This module provides conversions from [`TrustedServerError`] to HTTP responses. - -use error_stack::Report; -use fastly::Response; -use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; - -/// Converts a [`TrustedServerError`] into an HTTP error response. -pub fn to_error_response(report: &Report) -> Response { - // Get the root error for status code and message - let root_error = report.current_context(); - - // Log the full error chain for debugging - log::error!("Error occurred: {:?}", report); - - Response::from_status(root_error.status_code()) - .with_body_text_plain(&format!("{}\n", root_error.user_message())) -} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 489c4982d..f841e056d 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,4 +1,3 @@ -use std::net::IpAddr; use std::sync::Arc; use edgezero_adapter_fastly::config_store::FastlyConfigStore as EdgeZeroFastlyConfigStore; @@ -6,184 +5,53 @@ use edgezero_adapter_fastly::request::into_core_request; use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; -use edgezero_core::http::{ - header, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, -}; +use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; use edgezero_core::response::IntoResponse; use error_stack::Report; use fastly::http::Method as FastlyMethod; -use fastly::{ - ConfigStore as FastlyConfigStore, Request as FastlyRequest, Response as FastlyResponse, -}; +use fastly::{Request as FastlyRequest, Response as FastlyResponse}; -use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::AuctionOrchestrator; -use trusted_server_core::auth::enforce_basic_auth; -use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; -use trusted_server_core::ec::batch_sync::handle_batch_sync; -use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::finalize::ec_finalize_response; -use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ build_pull_sync_context, dispatch_pull_sync, PullSyncContext, }; use trusted_server_core::ec::registry::PartnerRegistry; -use trusted_server_core::ec::EcContext; -use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; -use trusted_server_core::geo::GeoInfo; -use trusted_server_core::http_util::is_navigation_request; -use trusted_server_core::integrations::{ - IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, - RequestFilterRegistryOutcome, -}; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; -use trusted_server_core::proxy::{ - handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, - handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, stream_asset_body, - AssetProxyCachePolicy, -}; -use trusted_server_core::publisher::{ - handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, - OwnedProcessResponseParams, PublisherResponse, -}; -use trusted_server_core::request_signing::{ - handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, - handle_verify_signature, -}; +use trusted_server_core::proxy::{stream_asset_body, AssetProxyCachePolicy}; +use trusted_server_core::publisher::stream_publisher_body; use trusted_server_core::settings::Settings; -use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; mod app; mod backend; mod compat; mod ec_kv; -mod error; mod logging; mod management_api; mod middleware; mod platform; mod rate_limiter; -#[cfg(test)] -mod route_tests; -use crate::app::{build_state, load_settings_from_config_store, EcFinalizeState, TrustedServerApp}; +use crate::app::{ + load_settings_from_config_store, EcFinalizeState, PublisherStreamState, TrustedServerApp, +}; use crate::ec_kv::FastlyEcKvStore; -use crate::error::to_error_response; use crate::middleware::{apply_finalize_headers, resolve_geo_for_response, HEADER_X_TS_FINALIZED}; -use crate::platform::{build_runtime_services, client_info_from_request, FastlyPlatformGeo}; +use crate::platform::{client_info_from_request, FastlyPlatformGeo}; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; -const EDGEZERO_ENABLED_KEY: &str = "edgezero_enabled"; -const EDGEZERO_ROLLOUT_PCT_KEY: &str = "edgezero_rollout_pct"; - -/// Result of routing a request, distinguishing buffered from streaming publisher responses. -/// -/// The streaming arm keeps the publisher body out of WASM heap until it is written directly -/// to the client via [`fastly::Response::stream_to_client`]. All other legacy routes are buffered. -/// -/// [`AuthChallenge`](HandlerOutcome::AuthChallenge) marks responses produced by this server's -/// own `enforce_basic_auth` so the geo-lookup gate can distinguish them from origin-forwarded -/// 401s, which should still carry geo headers. -enum HandlerOutcome { - Buffered(HttpResponse), - AuthChallenge(HttpResponse), - Streaming { - response: HttpResponse, - body: EdgeBody, - params: OwnedProcessResponseParams, - }, - AssetStreaming { - response: HttpResponse, - body: EdgeBody, - }, -} - -impl HandlerOutcome { - #[cfg(test)] - fn status(&self) -> edgezero_core::http::StatusCode { - match self { - HandlerOutcome::Buffered(resp) | HandlerOutcome::AuthChallenge(resp) => resp.status(), - HandlerOutcome::Streaming { response, .. } - | HandlerOutcome::AssetStreaming { response, .. } => response.status(), - } - } -} - -/// Returns `true` if the raw config-store value represents an enabled flag. -/// -/// Accepted values (after whitespace trimming): `"1"` or `"true"` in any ASCII case. -/// All other values, including the empty string, are treated as disabled. -fn parse_edgezero_flag(value: &str) -> bool { - let v = value.trim(); - v.eq_ignore_ascii_case("true") || v == "1" -} - -/// Parses a rollout percentage string into a value in `0..=100`. -/// -/// Accepts only integer strings in the range 0–100 (inclusive) after whitespace -/// trimming. Returns `None` for anything else: non-integer, out-of-range, -/// empty string. -fn parse_rollout_pct(value: &str) -> Option { - let n: u16 = value.trim().parse().ok()?; - if n > 100 { - return None; - } - Some(n as u8) -} - -/// Maps an arbitrary string to a deterministic bucket in `0..100`. -/// -/// Uses FNV-1a (32-bit variant) to produce a uniform-enough distribution for -/// canary traffic splitting without pulling in any hash crates. The same input -/// always produces the same output across Rust versions because the algorithm -/// is defined here, not delegated to `DefaultHasher`. -fn fnv1a_bucket(key: &str) -> u8 { - const FNV_OFFSET: u32 = 2_166_136_261; - const FNV_PRIME: u32 = 16_777_619; - let mut hash = FNV_OFFSET; - for byte in key.as_bytes() { - hash ^= u32::from(*byte); - hash = hash.wrapping_mul(FNV_PRIME); - } - (hash % 100) as u8 -} -/// Returns `true` if the given bucket should be routed to the `EdgeZero` path. -/// -/// `bucket` must be in `0..100`; `rollout_pct` in `0..=100`. -/// When `rollout_pct = 0` no bucket ever routes to `EdgeZero` (instant rollback). -/// When `rollout_pct = 100` every bucket routes to `EdgeZero` (full cutover). -fn routes_to_edgezero(bucket: u8, rollout_pct: u8) -> bool { - debug_assert!(bucket < 100, "should be a value produced by fnv1a_bucket"); - debug_assert!( - rollout_pct <= 100, - "should be a value produced by read_rollout_pct" - ); - bucket < rollout_pct -} - -/// Opens the existing Fastly Config Store used by the `EdgeZero` rollout flag. -/// -/// This preserves the pre-PR bootstrap behavior: `edgezero_enabled` and -/// `edgezero_rollout_pct` live in `trusted_server_config`, while the Trusted -/// Server app-config blob lives in the `EdgeZero` `app_config` store. +/// Opens the Fastly Config Store used by the `EdgeZero` dispatcher. /// /// # Errors /// /// Returns [`fastly::Error`] if the config store cannot be opened. -fn open_trusted_server_config_store() -> Result { - FastlyConfigStore::try_open(TRUSTED_SERVER_CONFIG_STORE).map_err(|e| { - fastly::Error::msg(format!( - "failed to open config store `{TRUSTED_SERVER_CONFIG_STORE}`: {e}" - )) - }) -} - -fn edgezero_config_store_handle() -> Result { +fn open_trusted_server_config_store() -> Result { let store = EdgeZeroFastlyConfigStore::try_open(TRUSTED_SERVER_CONFIG_STORE).map_err(|e| { fastly::Error::msg(format!( "failed to open config store `{TRUSTED_SERVER_CONFIG_STORE}`: {e}" @@ -192,124 +60,6 @@ fn edgezero_config_store_handle() -> Result { Ok(ConfigStoreHandle::new(Arc::new(store))) } -/// Reads the `edgezero_enabled` key from the prepared Fastly Config Store -/// handle. -/// -/// Returns `Err` on any key-read failure, so callers should use the legacy path -/// as the safe default. -/// -/// # Errors -/// -/// - [`fastly::Error`] if the key cannot be read. -fn is_edgezero_enabled(config_store: &FastlyConfigStore) -> Result { - let value = config_store - .try_get(EDGEZERO_ENABLED_KEY) - .map_err(|e| fastly::Error::msg(format!("failed to read edgezero_enabled: {e}")))?; - Ok(value.as_deref().is_some_and(parse_edgezero_flag)) -} - -/// Reads `edgezero_rollout_pct` from the config store. -/// -/// | Config store state | Return value | Effect | -/// |---------------------------------|--------------|----------------------------| -/// | Key absent | `0` | All legacy (safe default) | -/// | Key present, valid 0–100 | parsed value | Partial or full rollout | -/// | Key present, invalid | `0` | All legacy (safe default) | -/// | Key read error | `0` | All legacy (safe default) | -fn read_rollout_pct(config_store: &FastlyConfigStore) -> u8 { - rollout_pct_from_store_result(config_store.try_get(EDGEZERO_ROLLOUT_PCT_KEY)) -} - -fn rollout_pct_from_store_result(value: Result, E>) -> u8 -where - E: core::fmt::Display, -{ - match value { - Ok(Some(value)) => match parse_rollout_pct(&value) { - Some(pct) => pct, - None => { - log::warn!("invalid edgezero_rollout_pct value, defaulting to 0 (legacy path)"); - 0 - } - }, - Ok(None) => { - // Absent key fails safe to legacy, matching every other failure branch - // (unreadable flag, invalid value, read error all resolve to legacy). - // Deleting the key can never trigger a full cutover. Fires per-request - // when the key is absent and edgezero_enabled=true, so emit at debug to - // avoid a per-request log flood at production QPS. - log::debug!( - "edgezero_rollout_pct key absent, defaulting to 0 (legacy path — fail safe)" - ); - 0 - } - Err(e) => { - log::warn!("failed to read edgezero_rollout_pct: {e}, defaulting to 0 (legacy path)"); - 0 - } - } -} - -/// Decides whether a request routes to the `EdgeZero` path for the given rollout state. -/// -/// `rollout_pct` must be in `0..=100` (as produced by [`read_rollout_pct`]). -/// -/// The degenerate rollout values short-circuit the per-request routing-key -/// allocation and FNV hash, which together cover most of the canary's lifetime: -/// `0` always routes to legacy (full rollback) and `100` always routes to -/// `EdgeZero` (full cutover). Only the partial-rollout path hashes the client IP -/// (or the empty string when absent) into a `0..100` bucket via [`fnv1a_bucket`] -/// and compares it against `rollout_pct` with [`routes_to_edgezero`]. -fn should_route_to_edgezero(rollout_pct: u8, client_ip: Option) -> bool { - match rollout_pct { - 0 => { - log::debug!("routing request through legacy path (rollout_pct=0)"); - false - } - 100 => { - log::debug!("routing request through EdgeZero path (rollout_pct=100)"); - true - } - pct => { - let routing_key = match client_ip { - Some(ip) => ip.to_string(), - None => { - log::debug!( - "no client IP available, using empty routing key (deterministic bucket 61)" - ); - String::new() - } - }; - let bucket = fnv1a_bucket(&routing_key); - let routed = routes_to_edgezero(bucket, pct); - log::debug!( - "routing request through {} path (bucket={bucket}, rollout_pct={pct})", - if routed { "EdgeZero" } else { "legacy" } - ); - routed - } - } -} - -/// Selects the Fastly entry point for an already-opened config store and request. -/// -/// `edgezero_enabled=false` always routes to legacy and must not require callers -/// to read `edgezero_rollout_pct`. When enabled, `rollout_pct` is interpreted -/// exactly as [`read_rollout_pct`] returns it: `0` is full rollback, `100` is full -/// cutover, and intermediate values are sticky by client-IP bucket. -fn select_edgezero_entrypoint( - edgezero_enabled: bool, - rollout_pct: u8, - client_ip: Option, -) -> bool { - if !edgezero_enabled { - log::debug!("routing request through legacy path (edgezero_enabled=false)"); - return false; - } - - should_route_to_edgezero(rollout_pct, client_ip) -} - fn health_response(req: &FastlyRequest) -> Option { if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { return Some(FastlyResponse::from_status(200).with_body_text_plain("ok")); @@ -318,24 +68,10 @@ fn health_response(req: &FastlyRequest) -> Option { None } -/// Combined result from `route_request`, bundling the handler outcome with the -/// EC context and cookies needed for post-send finalization and pull sync. -struct RouteResult { - outcome: HandlerOutcome, - ec_context: EcContext, - finalize_kv_graph: Option, - eids_cookie: Option, - sharedid_cookie: Option, - is_real_browser: bool, - should_finalize_ec: bool, - asset_cache_policy: AssetProxyCachePolicy, - request_filter_effects: RequestFilterEffects, -} - /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `FastlyRequest::from_client()` instead of -/// `#[fastly::main]` so the legacy streaming publisher path can call +/// `#[fastly::main]` so the `EdgeZero` streaming publisher path can call /// [`fastly::Response::stream_to_client`] explicitly. fn main() { let req = FastlyRequest::from_client(); @@ -347,49 +83,14 @@ fn main() { } logging::init_logger(); - - let edgezero_config_store = match open_trusted_server_config_store() { - Ok(config_store) => config_store, - Err(e) => { - log::warn!("failed to open EdgeZero config store, falling back to legacy path: {e}"); - legacy_main(req); - return; - } - }; - - let edgezero_enabled = is_edgezero_enabled(&edgezero_config_store).unwrap_or_else(|e| { - log::warn!("failed to read edgezero_enabled flag, falling back to legacy path: {e}"); - false - }); - let route_to_edgezero = if edgezero_enabled { - let rollout_pct = read_rollout_pct(&edgezero_config_store); - select_edgezero_entrypoint(edgezero_enabled, rollout_pct, req.get_client_ip_addr()) - } else { - select_edgezero_entrypoint(edgezero_enabled, 0, req.get_client_ip_addr()) - }; - - if route_to_edgezero { - let edgezero_config_store = match edgezero_config_store_handle() { - Ok(config_store) => config_store, - Err(e) => { - log::warn!( - "failed to open EdgeZero config store handle, falling back to legacy path: {e}" - ); - legacy_main(req); - return; - } - }; - edgezero_main(req, edgezero_config_store); - } else { - legacy_main(req); - } + edgezero_main(req); } /// Handles a request through the `EdgeZero` router path. -fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { - // Short-circuit the JA4 debug probe before app construction, mirroring - // legacy_main. Must run here because TLS/JA4 accessors are only available - // on FastlyRequest before conversion to edgezero types. +fn edgezero_main(mut req: FastlyRequest) { + // Short-circuit the JA4 debug probe before app construction. Must run here + // because TLS/JA4 accessors are only available on FastlyRequest before + // conversion to edgezero types. if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { match load_settings_from_config_store() { Ok(settings) if settings.debug.ja4_endpoint_enabled => { @@ -408,18 +109,27 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { return; } + let config_store = match open_trusted_server_config_store() { + Ok(cs) => cs, + Err(e) => { + log::error!("failed to open config store: {e}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return; + } + }; + let (app, app_state) = TrustedServerApp::build_app_with_state(); let settings_snapshot = app_state.as_ref().map(|state| Arc::clone(&state.settings)); - // Strip client-spoofable forwarded headers before handing off to the - // EdgeZero dispatcher, mirroring the sanitization done in legacy_main. + // Strip client-spoofable forwarded headers before dispatch. compat::sanitize_fastly_forwarded_headers(&mut req); // Re-inject a trusted TLS scheme signal after sanitization has stripped any // client-sent fastly-ssl header. Setting it from Fastly's native TLS - // metadata here is authoritative. detect_request_scheme in http_util - // checks this header so scheme-sensitive logic (publisher URL rewriting, - // etc.) produces https URLs on HTTPS traffic, matching legacy path parity. + // metadata here is authoritative. detect_request_scheme in http_util checks + // this header so scheme-sensitive logic produces https URLs on HTTPS traffic. if req.get_tls_protocol().ok().flatten().is_some() || req.get_tls_cipher_openssl_name().ok().flatten().is_some() { @@ -429,11 +139,8 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { // Capture client IP before the request is consumed by dispatch. let client_ip = req.get_client_ip_addr(); - // Strip any client-supplied x-ts-tls-* headers before injecting the - // trusted values from the Fastly SDK. Without this, a plain-HTTP request - // carrying X-TS-TLS-Protocol: TLSv1.3 would sail through and cause - // detect_request_scheme to return "https", spoofing cookie Secure and - // URL rewriting. Must run after sanitize_fastly_forwarded_headers. + // Strip any client-supplied x-ts-tls-* headers before injecting the trusted + // values from the Fastly SDK. Must run after sanitize_fastly_forwarded_headers. req.remove_header("x-ts-tls-protocol"); req.remove_header("x-ts-tls-cipher"); if let Some(proto) = req.get_tls_protocol().ok().flatten().map(str::to_owned) { @@ -448,80 +155,40 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { req.set_header("x-ts-tls-cipher", cipher); } - // Capture the full ClientInfo (TLS protocol/cipher, JA4, H2 fingerprint, and - // server hostname/region) from the original FastlyRequest before conversion. - // These accessors only return real values on the client request; the - // reconstructed EdgeZero request cannot expose them. Stored in the request - // extensions so `build_per_request_services` reads the authoritative metadata - // that integration bot protection (e.g. DataDome) serializes, instead of - // defaulting those fields to empty as the EdgeZero context alone would. + // Capture metadata from the original FastlyRequest before conversion. These + // accessors only return real values on the client request, so store them in + // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req); - - // Derive device signals from the original FastlyRequest before conversion. - // Fastly's `get_tls_ja4()` and `get_client_h2_fingerprint()` accessors only - // return real values on the client request; a synthetic request rebuilt from - // EdgeZero HTTP types cannot expose them, which would strip the JA4/H2 class - // the EC bot gate needs and misclassify real browsers as bots. Stored in the - // request extensions so `build_ec_request_state` reads the authoritative - // signals instead of re-deriving from the reconstructed request. let device_signals = derive_device_signals(&req); // Dispatch directly through the EdgeZero router without an intermediate - // fastly::Response conversion. The standard dispatch helpers - // (dispatch_with_config_handle, etc.) convert through fastly::Response using - // set_header, which drops duplicate header values — silently losing multiple - // Set-Cookie headers from publisher/origin responses. - // - // Bypassing to app.router().oneshot() preserves every header value in the - // http::HeaderMap and skips the logger-reinit that prevents using run_app_*. - let mut response = { - match into_core_request(req) { - Ok(mut core_req) => { - core_req.extensions_mut().insert(config_store); - core_req.extensions_mut().insert(device_signals); - core_req.extensions_mut().insert(client_info); - match futures::executor::block_on(app.router().oneshot(core_req)) { - Ok(response) => response, - Err(error) => edge_error_response(error), - } - } - Err(e) => { - log::error!("EdgeZero request conversion failed: {e}"); - FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain("Internal Server Error") - .send_to_client(); - return; + // fastly::Response conversion. That preserves duplicate header values such + // as multiple Set-Cookie headers. + let mut response = match into_core_request(req) { + Ok(mut core_req) => { + core_req.extensions_mut().insert(config_store); + core_req.extensions_mut().insert(device_signals); + core_req.extensions_mut().insert(client_info); + match futures::executor::block_on(app.router().oneshot(core_req)) { + Ok(response) => response, + Err(error) => edge_error_response(error), } } + Err(e) => { + log::error!("EdgeZero request conversion failed: {e}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return; + } }; - // Pop the EC finalize state that route handlers thread out via response - // extensions. Must happen before the fastly conversion, which drops - // extensions. - let ec_state = response - .extensions_mut() - .remove::(); - - // Pop the asset cache policy threaded out by the asset-route fallback. Must - // happen before the fastly conversion, which drops extensions. Reapplied - // after finalization below so protected directives (e.g. no-store on asset - // errors) survive operator `response_headers`, mirroring legacy_main's - // asset_cache_policy.apply_after_route_finalization. + // Pop response extensions before the Fastly conversion, which drops them. + let ec_state = response.extensions_mut().remove::(); let asset_cache_policy = response.extensions_mut().remove::(); - - // Pop the integration request-filter response effects (e.g. DataDome - // challenge/allow headers) threaded out by the dispatch path. Applied to the - // response after EC finalization and before send, mirroring legacy_main's - // `request_filter_effects` application. Must happen before the fastly - // conversion, which drops extensions. let request_filter_effects = response.extensions_mut().remove::(); if !take_finalize_sentinel(&mut response) { - // Apply finalize headers at the entry point so that router-level - // 405/404 responses for unregistered HTTP methods (e.g. TRACE, WebDAV - // verbs) carry TS/geo headers. Prefer the request-scope settings that - // built the EdgeZero router; startup-error routers have no valid state, - // so they preserve the previous best-effort reload fallback. if let Some(settings) = settings_snapshot.as_deref() { apply_entry_point_finalize_headers(settings, &mut response, client_ip); } else { @@ -536,17 +203,10 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { } } - // Reapply protected asset cache directives after finalization, mirroring - // legacy_main. A no-op for OriginControlled responses. if let Some(policy) = asset_cache_policy { policy.apply_after_route_finalization(&mut response); } - // EC response lifecycle, mirroring legacy_main: finalize EC cookies and - // request headers on the response, send it, then run pull sync for - // recognized browsers. Reuse the request-scope settings from AppState; - // startup-error responses have no valid state and keep the previous - // best-effort reload fallback. if let Some(ec_state) = ec_state { if let Some(settings) = settings_snapshot.as_deref() { match apply_edgezero_ec_finalize(settings, &ec_state, &mut response) { @@ -632,9 +292,6 @@ fn apply_edgezero_ec_finalize( response: &mut HttpResponse, ) -> Result> { let partner_registry = PartnerRegistry::from_config(&settings.ec.partners)?; - // KvIdentityGraph cannot ride in response extensions (non-Sync dyn - // EcKvStore), so rebuild it from settings when the handler enabled the KV - // write path. let finalize_kv_graph = if ec_state.use_finalize_kv { maybe_identity_graph(settings) } else { @@ -666,12 +323,9 @@ fn run_edgezero_pull_sync_after_send( /// Sends a finalized `EdgeZero` response to the client. /// -/// Asset responses carry an [`EdgeBody::Stream`] body: the headers are committed -/// first via [`fastly::Response::stream_to_client`], then the origin body is -/// piped chunk-by-chunk with [`stream_asset_body`] so large images never -/// materialize in the Wasm heap. This mirrors the legacy -/// `HandlerOutcome::AssetStreaming` path. All other responses carry a buffered -/// [`EdgeBody::Once`] body and are sent in a single shot. +/// Publisher and asset streams commit headers first, then pipe the origin body +/// chunk by chunk so large responses do not materialize in the Wasm heap. +/// Other responses are sent in one shot. fn send_edgezero_response( mut response: HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, @@ -680,7 +334,35 @@ fn send_edgezero_response( effects.apply_to_response(&mut response); } + let publisher_stream = response + .extensions_mut() + .remove::>(); let (parts, body) = response.into_parts(); + + if let Some(stream_state) = publisher_stream { + let skeleton = + compat::to_fastly_response_skeleton(HttpResponse::from_parts(parts, EdgeBody::empty())); + let mut streaming_body = skeleton.stream_to_client(); + match stream_publisher_body( + body, + &mut streaming_body, + &stream_state.params, + &stream_state.settings, + &stream_state.registry, + ) { + Ok(()) => { + if let Err(e) = streaming_body.finish() { + log::error!("failed to finish EdgeZero publisher streaming body: {e}"); + } + } + Err(e) => { + log::error!("EdgeZero publisher streaming failed: {e:?}"); + drop(streaming_body); + } + } + return; + } + match body { EdgeBody::Stream(_) => { let skeleton = compat::to_fastly_response_skeleton(HttpResponse::from_parts( @@ -696,8 +378,6 @@ fn send_edgezero_response( } Err(e) => { log::error!("EdgeZero asset streaming failed: {e:?}"); - // Headers already committed; drop the body so the client sees - // a truncated response (EOF mid-stream), standard proxy behavior. drop(streaming_body); } } @@ -708,213 +388,6 @@ fn send_edgezero_response( } } -/// Handles a request using the original Fastly-native entry point. -/// -/// Preserves identical semantics to the pre-PR14 `main()`, with one -/// relocation: `GET /health` is short-circuited in [`main`] before the flag -/// dispatch, so it never reaches this function. The pre-PR14 entry point -/// answered `/health` with the same `200 ok` before settings loading and -/// routing; the only difference is that the probe now also skips logger -/// initialization. Called whenever -/// the `EdgeZero` flag is disabled or cannot be read/parsed as enabled — that -/// includes config-store open failures, key-read errors, missing keys, and -/// any value other than the accepted `"true"` / `"1"` forms. -/// -/// The thin fastly↔http conversion layer (via `compat::from_fastly_request` / -/// `compat::to_fastly_response`) lives here in the adapter crate. -// TODO: delete after Phase 5 EdgeZero cutover — see issue #495 -fn legacy_main(mut req: FastlyRequest) { - let state = match build_state() { - Ok(state) => state, - Err(e) => { - log::error!("Failed to build application state: {:?}", e); - to_error_response(&e).send_to_client(); - return; - } - }; - // lgtm[rust/cleartext-logging] - // `Settings` uses `Redacted` for secrets, so this debug dump is redacted. - log::debug!("Settings {:?}", state.settings); - - // Short-circuit the ja4 debug probe before finalize_response so that - // Cache-Control: no-store, private cannot be replaced by operator [response_headers]. - if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { - if state.settings.debug.ja4_endpoint_enabled { - build_ja4_debug_response(&req).send_to_client(); - } else { - FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); - } - return; - } - - let partner_registry = match PartnerRegistry::from_config(&state.settings.ec.partners) { - Ok(registry) => registry, - Err(e) => { - log::error!("Failed to build partner registry: {:?}", e); - to_error_response(&e).send_to_client(); - return; - } - }; - - // Strip client-spoofable forwarded headers at the edge before building - // any request-derived context or converting to the core HTTP types. - compat::sanitize_fastly_forwarded_headers(&mut req); - - let device_signals = derive_device_signals(&req); - let runtime_services = - build_runtime_services(&req, std::sync::Arc::clone(&state.default_kv_store)); - let http_req = compat::from_fastly_request(req); - - let route_result = futures::executor::block_on(route_request( - &state.settings, - &state.orchestrator, - &state.registry, - &partner_registry, - &runtime_services, - http_req, - device_signals, - )) - .unwrap_or_else(|e| RouteResult { - outcome: HandlerOutcome::Buffered(http_error_response(&e)), - ec_context: EcContext::default(), - finalize_kv_graph: None, - eids_cookie: None, - sharedid_cookie: None, - is_real_browser: false, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: RequestFilterEffects::default(), - }); - - let RouteResult { - outcome, - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec, - asset_cache_policy, - request_filter_effects, - } = route_result; - - // Skip geo lookup for our own auth challenges: avoids exposing geo headers to - // unauthenticated callers. Origin-forwarded 401s are not AuthChallenge and - // do receive geo headers — the client already reached the origin anyway. - let geo_info = if matches!(outcome, HandlerOutcome::AuthChallenge(_)) { - None - } else { - runtime_services - .geo() - .lookup(runtime_services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }) - }; - - match outcome { - HandlerOutcome::Buffered(mut response) | HandlerOutcome::AuthChallenge(mut response) => { - finalize_response(&state.settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); - if should_finalize_ec { - ec_finalize_response( - &state.settings, - &ec_context, - finalize_kv_graph.as_ref(), - &partner_registry, - eids_cookie.as_deref(), - sharedid_cookie.as_deref(), - &mut response, - ); - } - request_filter_effects.apply_to_response(&mut response); - compat::to_fastly_response(response).send_to_client(); - - if is_real_browser { - if let Some(context) = build_pull_sync_context(&ec_context) { - run_pull_sync_after_send( - &state.settings, - &partner_registry, - &context, - &runtime_services, - ); - } - } - } - HandlerOutcome::Streaming { - mut response, - body, - params, - } => { - finalize_response(&state.settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); - if should_finalize_ec { - ec_finalize_response( - &state.settings, - &ec_context, - finalize_kv_graph.as_ref(), - &partner_registry, - eids_cookie.as_deref(), - sharedid_cookie.as_deref(), - &mut response, - ); - } - request_filter_effects.apply_to_response(&mut response); - let fastly_resp = compat::to_fastly_response_skeleton(response); - let mut streaming_body = fastly_resp.stream_to_client(); - let mut stream_succeeded = false; - match stream_publisher_body( - body, - &mut streaming_body, - ¶ms, - &state.settings, - &state.registry, - ) { - Ok(()) => { - if let Err(e) = streaming_body.finish() { - log::error!("failed to finish streaming body: {e}"); - } else { - stream_succeeded = true; - } - } - Err(e) => { - log::error!("streaming processing failed: {e:?}"); - // Headers already committed. Drop the body so the client sees a - // truncated response (EOF mid-stream) — standard proxy behavior. - drop(streaming_body); - } - } - - if is_real_browser && stream_succeeded { - if let Some(context) = build_pull_sync_context(&ec_context) { - run_pull_sync_after_send( - &state.settings, - &partner_registry, - &context, - &runtime_services, - ); - } - } - } - HandlerOutcome::AssetStreaming { mut response, body } => { - finalize_response(&state.settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); - request_filter_effects.apply_to_response(&mut response); - let fastly_resp = compat::to_fastly_response_skeleton(response); - let mut streaming_body = fastly_resp.stream_to_client(); - if let Err(e) = - futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) - { - log::error!("asset streaming failed: {e:?}"); - drop(streaming_body); - } else if let Err(e) = streaming_body.finish() { - log::error!("failed to finish asset streaming body: {e}"); - } - } - } -} - const FALLBACK_UNAVAILABLE: &str = "unavailable"; const FALLBACK_NOT_SENT: &str = "not sent"; const FALLBACK_NONE: &str = "none"; @@ -963,418 +436,6 @@ fn build_ja4_debug_response(req: &FastlyRequest) -> FastlyResponse { .with_body(body) } -async fn route_request( - settings: &Settings, - orchestrator: &AuctionOrchestrator, - integration_registry: &IntegrationRegistry, - partner_registry: &PartnerRegistry, - runtime_services: &RuntimeServices, - mut req: HttpRequest, - device_signals: DeviceSignals, -) -> Result> { - let is_real_browser = device_signals.looks_like_browser(); - - if !is_real_browser { - log::info!( - "Bot gate: blocking EC operations (ja4={:?}, platform={:?}, is_mobile={})", - device_signals.ja4_class, - device_signals.platform_class, - device_signals.is_mobile, - ); - } - - // Extract the Prebid EIDs and SharedID cookies before routing. - let eids_cookie = extract_cookie_value(&req, COOKIE_TS_EIDS); - let sharedid_cookie = extract_cookie_value(&req, COOKIE_SHAREDID); - - // Extract geo info. - let geo_info = runtime_services - .geo() - .lookup(runtime_services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed during routing: {e}"); - None - }); - - // S2S batch sync — uses Bearer auth (not EC cookies), so skip EC - // context creation and the EC finalize middleware entirely. - if req.method() == Method::POST && req.uri().path() == "/_ts/api/v1/batch-sync" { - match enforce_basic_auth(settings, &req) { - Ok(Some(response)) => { - return Ok(RouteResult { - outcome: HandlerOutcome::AuthChallenge(response), - ec_context: EcContext::default(), - finalize_kv_graph: None, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: RequestFilterEffects::default(), - }); - } - Ok(None) => {} - Err(e) => return Err(e), - } - let result = require_identity_graph(settings).and_then(|kv| { - let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, partner_registry, &limiter, req) - }); - let outcome = match result { - Ok(resp) => HandlerOutcome::Buffered(resp), - Err(e) => HandlerOutcome::Buffered(http_error_response(&e)), - }; - return Ok(RouteResult { - outcome, - ec_context: EcContext::default(), - finalize_kv_graph: None, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: RequestFilterEffects::default(), - }); - } - - // Build EC context using the fastly request reference (headers/method/URI). - let mut ec_context = match EcContext::read_from_request_with_geo( - settings, - &req, - runtime_services, - geo_info.as_ref(), - ) { - Ok(context) => context, - Err(err) => { - return Ok(RouteResult { - outcome: HandlerOutcome::Buffered(http_error_response(&err)), - ec_context: EcContext::default(), - finalize_kv_graph: None, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: RequestFilterEffects::default(), - }); - } - }; - - // Pass device signals to EcContext so they are stored on new entries. - ec_context.set_device_signals(device_signals); - - // Bot gate: suppress KV-backed EC writes for unrecognized clients, except - // consent withdrawals. Revocations need the KV graph so tombstones remain - // authoritative even for privacy-extension-heavy clients that do not look - // like known browsers. - // Build the KV identity graph once. The write-path (finalize_kv_graph) is - // also given to bots when they signal consent withdrawal so tombstones are - // authoritative even for privacy-extension-heavy clients. - let kv_graph = maybe_identity_graph(settings); - let finalize_kv_graph = if is_real_browser || ec_consent_withdrawn(ec_context.consent()) { - kv_graph.clone() - } else { - None - }; - let kv_graph = if is_real_browser { kv_graph } else { None }; - - // `load_settings_from_config_store()` should already have rejected invalid handler regexes. - // Keep this fallback so manually-constructed or otherwise unprepared - // settings still become an error response instead of panicking. - match enforce_basic_auth(settings, &req) { - Ok(Some(response)) => { - return Ok(RouteResult { - outcome: HandlerOutcome::AuthChallenge(response), - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: RequestFilterEffects::default(), - }); - } - Ok(None) => {} - Err(e) => return Err(e), - } - - let request_filter_effects = match integration_registry - .filter_request(RequestFilterRegistryInput { - settings, - services: runtime_services, - req: &mut req, - geo_info: geo_info.as_ref(), - }) - .await - { - Ok(RequestFilterRegistryOutcome::Continue(effects)) => effects, - Ok(RequestFilterRegistryOutcome::Respond { response, effects }) => { - return Ok(RouteResult { - outcome: HandlerOutcome::Buffered(*response), - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: effects, - }); - } - Err(e) => { - log::error!("Failed to run integration request filters: {:?}", e); - return Ok(RouteResult { - outcome: HandlerOutcome::Buffered(http_error_response(&e)), - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: RequestFilterEffects::default(), - }); - } - }; - - // Get path and method for routing - let path = req.uri().path().to_string(); - let method = req.method().clone(); - - let mut asset_cache_policy = AssetProxyCachePolicy::OriginControlled; - let mut should_finalize_ec = true; - - // Match known routes and handle them - let (result, organic_route) = match (method, path.as_str()) { - // Serve the tsjs library - (Method::GET, path) if path.starts_with("/static/tsjs=") => { - (handle_tsjs_dynamic(&req, integration_registry), false) - } - - // Discovery endpoint for trusted-server capabilities and JWKS - (Method::GET, "/.well-known/trusted-server.json") => ( - handle_trusted_server_discovery(settings, runtime_services, req), - false, - ), - - // Signature verification endpoint - (Method::POST, "/verify-signature") => ( - handle_verify_signature(settings, runtime_services, req), - false, - ), - - // Admin endpoints - // Keep in sync with Settings::ADMIN_ENDPOINTS in crates/trusted-server-core/src/settings.rs - // - // Only the canonical `/_ts/admin/keys/*` paths execute key operations. - // The legacy `/admin/keys/*` aliases are denied locally below so stale - // clients do not leak admin Basic credentials or key-management payloads - // to the publisher origin via the organic fallback. - ( - Method::GET - | Method::POST - | Method::HEAD - | Method::OPTIONS - | Method::PUT - | Method::PATCH - | Method::DELETE, - "/admin/keys/rotate" | "/admin/keys/deactivate", - ) => (Ok(legacy_admin_alias_denied()), false), - (Method::POST, "/_ts/admin/keys/rotate") => { - (handle_rotate_key(settings, runtime_services, req), false) - } - (Method::POST, "/_ts/admin/keys/deactivate") => ( - handle_deactivate_key(settings, runtime_services, req), - false, - ), - (Method::GET, "/_ts/api/v1/identify") => { - let outcome = require_identity_graph(settings) - .and_then(|kv| handle_identify(settings, &kv, partner_registry, &req, &ec_context)); - (outcome, false) - } - (Method::OPTIONS, "/_ts/api/v1/identify") => { - let outcome = cors_preflight_identify(settings, &req); - (outcome, false) - } - - // Tester-cookie endpoints (disabled unless `[tester_cookie].enabled`). - (Method::GET, "/_ts/set-tester") => (handle_set_tester(settings), false), - (Method::GET, "/_ts/clear-tester") => (handle_clear_tester(settings), false), - - // Unified auction endpoint. - (Method::POST, "/auction") => { - let registry_ref = if partner_registry.is_empty() { - None - } else { - Some(partner_registry) - }; - ( - handle_auction( - settings, - orchestrator, - kv_graph.as_ref(), - registry_ref, - &ec_context, - runtime_services, - req, - ) - .await, - false, - ) - } - - // First-party proxy/click/sign/rebuild endpoints - (Method::GET, "/first-party/proxy") => ( - handle_first_party_proxy(settings, runtime_services, req).await, - false, - ), - (Method::GET, "/first-party/click") => ( - handle_first_party_click(settings, runtime_services, req).await, - false, - ), - (Method::GET, "/first-party/sign") | (Method::POST, "/first-party/sign") => ( - handle_first_party_proxy_sign(settings, runtime_services, req).await, - false, - ), - (Method::POST, "/first-party/proxy-rebuild") => ( - handle_first_party_proxy_rebuild(settings, runtime_services, req).await, - false, - ), - (m, path) if integration_registry.has_route(&m, path) => { - let result = integration_registry - .handle_proxy(ProxyDispatchInput { - method: &m, - path, - settings, - kv: kv_graph.as_ref(), - ec_context: &mut ec_context, - services: runtime_services, - req, - }) - .await - .unwrap_or_else(|| { - Err(Report::new(TrustedServerError::BadRequest { - message: format!("Unknown integration route: {path}"), - })) - }); - (result, true) - } - - // No known route matched, proxy to an asset origin or publisher origin as fallback - (method, _) => { - let matched_asset_route = matches!(method, Method::GET | Method::HEAD) - .then(|| settings.asset_route_for_path(&path)) - .flatten(); - - if let Some(asset_route) = matched_asset_route { - should_finalize_ec = false; - log::info!("No explicit route matched; proxying via configured asset route"); - let result = - match handle_asset_proxy_request(settings, runtime_services, req, asset_route) - .await - { - Ok(asset_response) => { - asset_cache_policy = asset_response.cache_policy(); - let (response, stream_body) = asset_response.into_response_and_body(); - if let Some(body) = stream_body { - return Ok(RouteResult { - outcome: HandlerOutcome::AssetStreaming { response, body }, - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec, - asset_cache_policy, - request_filter_effects, - }); - } - Ok(response) - } - Err(e) => { - asset_cache_policy = AssetProxyCachePolicy::NoStorePrivate; - Err(e) - } - }; - (result, false) - } else { - log::info!( - "No known route matched for path: {}, proxying to publisher origin", - path - ); - - // Generate EC ID if needed — mirrors the integration proxy path in registry.rs. - // Only for document navigations by recognised browsers; subresource requests - // may lack consent signals such as Sec-GPC. - if is_real_browser && is_navigation_request(&req) { - if let Err(err) = ec_context.generate_if_needed(settings, kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); - } - } - - match handle_publisher_request( - settings, - integration_registry, - runtime_services, - req, - ) - .await - { - Ok(PublisherResponse::Stream { - response, - body, - params, - }) => { - return Ok(RouteResult { - outcome: HandlerOutcome::Streaming { - response, - body, - params, - }, - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec, - asset_cache_policy, - request_filter_effects, - }); - } - Ok(PublisherResponse::PassThrough { mut response, body }) => { - *response.body_mut() = body; - (Ok(response), true) - } - Ok(PublisherResponse::Buffered(response)) => (Ok(response), true), - Err(e) => { - log::error!("Failed to proxy to publisher origin: {:?}", e); - (Err(e), true) - } - } - } - } - }; - - let _ = organic_route; - - let outcome = result - .map(HandlerOutcome::Buffered) - .unwrap_or_else(|e| HandlerOutcome::Buffered(http_error_response(&e))); - - Ok(RouteResult { - outcome, - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec, - asset_cache_policy, - request_filter_effects, - }) -} - pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option { settings .ec @@ -1401,49 +462,6 @@ fn run_pull_sync_after_send( dispatch_pull_sync(settings, &kv, partner_registry, &limiter, context, services); } -/// Applies all standard response headers: geo, version, staging, and configured headers. -/// -/// Called from every response path (including auth early-returns) so that all -/// outgoing responses carry a consistent set of Trusted Server headers. -/// -/// Header precedence (last write wins): geo headers are set first, then -/// version/staging, then operator-configured `settings.response_headers`. -/// This means operators can intentionally override any managed header. -fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { - apply_finalize_headers(settings, geo_info, response); -} - -/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` -/// aliases. -/// -/// These non-`/_ts` aliases are not matched by the `^/_ts/admin` basic-auth -/// handler, so they must fail closed locally rather than fall through to the -/// publisher fallback — which would forward the client's `Authorization` header -/// and key-management payload to the origin, leaking admin credentials. -fn legacy_admin_alias_denied() -> HttpResponse { - let mut response = HttpResponse::new(EdgeBody::from("Not found\n")); - *response.status_mut() = edgezero_core::http::StatusCode::NOT_FOUND; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - -fn http_error_response(report: &Report) -> HttpResponse { - let root_error = report.current_context(); - log::error!("Error occurred: {:?}", report); - - let mut response = - HttpResponse::new(EdgeBody::from(format!("{}\n", root_error.user_message()))); - *response.status_mut() = root_error.status_code(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - /// Constructs a `KvIdentityGraph` from settings, or returns an error if the /// `ec_store` config is not set. pub(crate) fn require_identity_graph( @@ -1487,7 +505,9 @@ pub(crate) fn derive_device_signals(req: &FastlyRequest) -> DeviceSignals { #[cfg(test)] mod tests { use super::*; + use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder; + use edgezero_core::http::HeaderValue; use fastly::mime; fn test_settings() -> Settings { @@ -1516,338 +536,6 @@ mod tests { .expect("should parse test settings") } - #[test] - fn parses_true_flag_values() { - assert!(parse_edgezero_flag("true"), "should parse 'true'"); - assert!(parse_edgezero_flag("1"), "should parse '1'"); - assert!(parse_edgezero_flag(" true "), "should trim whitespace"); - assert!( - parse_edgezero_flag(" 1 "), - "should trim whitespace around '1'" - ); - assert!(parse_edgezero_flag("TRUE"), "should parse uppercase 'TRUE'"); - assert!( - parse_edgezero_flag("True"), - "should parse mixed-case 'True'" - ); - } - - #[test] - fn rejects_non_true_flag_values() { - assert!(!parse_edgezero_flag("false"), "should not parse 'false'"); - assert!(!parse_edgezero_flag(""), "should not parse empty string"); - assert!( - !parse_edgezero_flag(" "), - "should not parse whitespace-only" - ); - assert!(!parse_edgezero_flag("yes"), "should not parse 'yes'"); - } - - // --------------------------------------------------------------------------- - // parse_rollout_pct - // --------------------------------------------------------------------------- - - #[test] - fn parses_valid_rollout_percentages() { - assert_eq!(parse_rollout_pct("0"), Some(0), "should parse '0'"); - assert_eq!(parse_rollout_pct("1"), Some(1), "should parse '1'"); - assert_eq!(parse_rollout_pct("50"), Some(50), "should parse '50'"); - assert_eq!(parse_rollout_pct("100"), Some(100), "should parse '100'"); - assert_eq!( - parse_rollout_pct(" 50 "), - Some(50), - "should trim whitespace" - ); - } - - #[test] - fn rejects_invalid_rollout_percentages() { - assert_eq!( - parse_rollout_pct("101"), - None, - "should reject values above 100" - ); - assert_eq!(parse_rollout_pct(""), None, "should reject empty string"); - assert_eq!(parse_rollout_pct("abc"), None, "should reject non-integer"); - assert_eq!( - parse_rollout_pct("-1"), - None, - "should reject negative value" - ); - assert_eq!( - parse_rollout_pct("1.5"), - None, - "should reject decimal value" - ); - } - - // --------------------------------------------------------------------------- - // fnv1a_bucket - // --------------------------------------------------------------------------- - - #[test] - fn bucket_is_in_range_0_to_99() { - for key in &["1.2.3.4", "255.255.255.255", "::1", "", "unknown"] { - let b = fnv1a_bucket(key); - assert!(b < 100, "bucket must be 0..100 for key {key:?}, got {b}"); - } - } - - #[test] - fn bucket_is_deterministic() { - let key = "192.168.1.1"; - assert_eq!( - fnv1a_bucket(key), - fnv1a_bucket(key), - "same key must produce the same bucket" - ); - } - - #[test] - fn bucket_matches_known_fnv1a_vector() { - // FNV-1a 32-bit: XOR-then-multiply. Verified against reference implementation. - assert_eq!( - fnv1a_bucket("1.2.3.4"), - 85, - "should match pinned FNV-1a vector" - ); - assert_eq!( - fnv1a_bucket(""), - 61, - "should match pinned FNV-1a vector for empty key" - ); - } - - #[test] - fn bucket_distributes_across_range() { - // Smoke-test that fnv1a_bucket produces a spread of values (not a constant). - // 256 distinct IP-like keys must produce at least 50 unique buckets. - let buckets: std::collections::HashSet = (0u16..=255) - .map(|i| fnv1a_bucket(&format!("10.0.0.{i}"))) - .collect(); - assert!( - buckets.len() > 50, - "fnv1a_bucket should distribute across buckets; got only {} unique values in 256 keys", - buckets.len() - ); - } - - #[test] - fn empty_key_bucket_is_valid() { - let b = fnv1a_bucket(""); - assert!( - b < 100, - "empty key must still produce a valid bucket, got {b}" - ); - } - - // --------------------------------------------------------------------------- - // routes_to_edgezero - // --------------------------------------------------------------------------- - - #[test] - fn rollout_zero_routes_all_to_legacy() { - for bucket in 0u8..100 { - assert!( - !routes_to_edgezero(bucket, 0), - "pct=0 should route all to legacy, bucket={bucket}" - ); - } - } - - #[test] - fn rollout_hundred_routes_all_to_edgezero() { - for bucket in 0u8..100 { - assert!( - routes_to_edgezero(bucket, 100), - "pct=100 should route all to EdgeZero, bucket={bucket}" - ); - } - } - - #[test] - fn rollout_fifty_routes_exactly_half_of_bucket_space() { - let edgezero_count = (0u8..100).filter(|&b| routes_to_edgezero(b, 50)).count(); - assert_eq!( - edgezero_count, 50, - "pct=50 should route exactly 50 out of 100 buckets to EdgeZero" - ); - } - - #[test] - fn rollout_one_routes_exactly_one_bucket() { - let edgezero_count = (0u8..100).filter(|&b| routes_to_edgezero(b, 1)).count(); - assert_eq!( - edgezero_count, 1, - "pct=1 should route exactly 1 out of 100 buckets to EdgeZero" - ); - } - - // --------------------------------------------------------------------------- - // should_route_to_edgezero — entry-point dispatch matrix - // --------------------------------------------------------------------------- - - #[test] - fn should_route_zero_pct_always_routes_to_legacy() { - let ip: IpAddr = "1.2.3.4".parse().expect("should parse IP"); - assert!( - !should_route_to_edgezero(0, Some(ip)), - "rollout_pct=0 should route to legacy regardless of client IP" - ); - assert!( - !should_route_to_edgezero(0, None), - "rollout_pct=0 should route to legacy when client IP is absent" - ); - } - - #[test] - fn should_route_hundred_pct_always_routes_to_edgezero() { - let ip: IpAddr = "1.2.3.4".parse().expect("should parse IP"); - assert!( - should_route_to_edgezero(100, Some(ip)), - "rollout_pct=100 should route to EdgeZero regardless of client IP" - ); - assert!( - should_route_to_edgezero(100, None), - "rollout_pct=100 should route to EdgeZero when client IP is absent" - ); - } - - #[test] - fn should_route_partial_buckets_client_ip() { - // "1.2.3.4" hashes to bucket 85 (pinned FNV-1a vector). - let ip: IpAddr = "1.2.3.4".parse().expect("should parse IP"); - assert!( - should_route_to_edgezero(86, Some(ip)), - "bucket 85 < 86 should route to EdgeZero (hit)" - ); - assert!( - !should_route_to_edgezero(85, Some(ip)), - "bucket 85 is not < 85 should route to legacy (miss)" - ); - } - - #[test] - fn should_route_partial_absent_ip_uses_empty_key_bucket() { - // The empty routing key hashes to bucket 61 (pinned FNV-1a vector). - assert!( - should_route_to_edgezero(62, None), - "bucket 61 < 62 should route to EdgeZero (hit)" - ); - assert!( - !should_route_to_edgezero(61, None), - "bucket 61 is not < 61 should route to legacy (miss)" - ); - } - - #[test] - fn entrypoint_selector_requires_enabled_flag_before_rollout() { - let ip: IpAddr = "1.2.3.4".parse().expect("should parse IP"); - - assert!( - !select_edgezero_entrypoint(false, 100, Some(ip)), - "disabled EdgeZero flag should force the legacy entry point even at 100% rollout" - ); - } - - #[test] - fn entrypoint_selector_routes_rollout_edges() { - let ip: IpAddr = "1.2.3.4".parse().expect("should parse IP"); - - assert!( - !select_edgezero_entrypoint(true, 0, Some(ip)), - "enabled EdgeZero with 0% rollout should route to legacy" - ); - assert!( - select_edgezero_entrypoint(true, 100, Some(ip)), - "enabled EdgeZero with 100% rollout should route to EdgeZero" - ); - } - - #[test] - fn entrypoint_selector_routes_partial_hit_and_miss() { - let ip: IpAddr = "1.2.3.4".parse().expect("should parse IP"); - - assert!( - select_edgezero_entrypoint(true, 86, Some(ip)), - "enabled EdgeZero should route a bucket hit to EdgeZero" - ); - assert!( - !select_edgezero_entrypoint(true, 85, Some(ip)), - "enabled EdgeZero should route a bucket miss to legacy" - ); - } - - // --------------------------------------------------------------------------- - // read_rollout_pct — safety-critical config-store branches - // --------------------------------------------------------------------------- - - // Canned config-store response so the safety-critical defaults can be pinned - // without a live Fastly Config Store. `ConfigStoreError` is not `Clone`, so the - // error case is built fresh inside `get` rather than stored. - enum StubResponse { - Value(String), - Absent, - Unavailable, - } - - fn rollout_result( - response: StubResponse, - ) -> Result, edgezero_core::config_store::ConfigStoreError> { - match response { - StubResponse::Value(v) => Ok(Some(v)), - StubResponse::Absent => Ok(None), - StubResponse::Unavailable => Err( - edgezero_core::config_store::ConfigStoreError::unavailable("boom"), - ), - } - } - - #[test] - fn read_rollout_pct_absent_defaults_to_legacy() { - assert_eq!( - rollout_pct_from_store_result(rollout_result(StubResponse::Absent)), - 0, - "absent key should fail safe to 0 (legacy), like every other failure branch" - ); - } - - #[test] - fn read_rollout_pct_valid_value_is_parsed() { - assert_eq!( - rollout_pct_from_store_result(rollout_result(StubResponse::Value("42".into()))), - 42, - "a valid in-range value should be returned verbatim" - ); - } - - #[test] - fn read_rollout_pct_invalid_value_defaults_to_zero() { - assert_eq!( - rollout_pct_from_store_result(rollout_result(StubResponse::Value("abc".into()))), - 0, - "an unparseable value should fail safe to 0 (legacy)" - ); - } - - #[test] - fn read_rollout_pct_out_of_range_defaults_to_zero() { - assert_eq!( - rollout_pct_from_store_result(rollout_result(StubResponse::Value("101".into()))), - 0, - "an out-of-range value should fail safe to 0 (legacy)" - ); - } - - #[test] - fn read_rollout_pct_read_error_defaults_to_zero() { - assert_eq!( - rollout_pct_from_store_result(rollout_result(StubResponse::Unavailable)), - 0, - "a config-store read error should fail safe to 0 (legacy)" - ); - } - #[test] fn health_response_short_circuits_get_health() { let req = FastlyRequest::get("https://example.com/health"); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index ceb470b7d..eb102eba9 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -1,7 +1,6 @@ -//! Middleware implementations for the dual-path entry point. +//! Middleware implementations for the `EdgeZero` entry point. //! -//! Provides two middleware types that mirror the finalization and auth logic -//! from the legacy [`crate::finalize_response`] and [`crate::route_request`]: +//! Provides two middleware types used by the `EdgeZero` entry point: //! //! - [`FinalizeResponseMiddleware`] — geo lookup and standard TS header injection //! - [`AuthMiddleware`] — basic-auth enforcement via [`enforce_basic_auth`] @@ -154,11 +153,12 @@ impl Middleware for AuthMiddleware { /// /// # Parity note /// -/// The legacy path skips geo only for its own `HandlerOutcome::AuthChallenge` -/// responses; origin-forwarded 401s still receive geo headers there. The `EdgeZero` -/// path skips geo for **all** 401s by status. This is intentionally more -/// conservative: geo data is not sent to any unauthenticated caller regardless of -/// whether the 401 originated from this server or the upstream origin. +/// Before legacy cleanup, the Fastly-native path skipped geo only for this +/// server's own auth challenges; origin-forwarded 401s still received geo +/// headers there. The `EdgeZero` path skips geo for **all** 401s by status. This +/// is intentionally more conservative: geo data is not sent to any +/// unauthenticated caller regardless of whether the 401 originated from this +/// server or the upstream origin. pub(crate) fn resolve_geo_for_response( response: &Response, client_ip: Option, @@ -180,8 +180,7 @@ where /// Applies all standard Trusted Server response headers to the given response. /// -/// Mirrors [`crate::finalize_response`] exactly, operating on [`Response`] from -/// `edgezero_core::http` instead of `HttpResponse`. +/// Operates on [`Response`] from `edgezero_core::http`. /// /// Header write order (last write wins): /// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9b1a73422..7e9921a20 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -1,9 +1,5 @@ //! Fastly-backed implementations of the platform traits defined in //! `trusted-server-core::platform`. -//! -//! This module also provides [`build_runtime_services`], a free function that -//! constructs a [`RuntimeServices`] instance once at the entry point from the -//! incoming Fastly request. use std::io::Read as _; use std::net::IpAddr; @@ -23,7 +19,7 @@ use trusted_server_core::platform::{ PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -561,44 +557,14 @@ impl PlatformGeo for FastlyPlatformGeo { } } -// --------------------------------------------------------------------------- -// Entry-point helper -// --------------------------------------------------------------------------- - -/// Construct a [`RuntimeServices`] instance from the incoming Fastly request. -/// -/// Call this once at the entry point before dispatching to handlers. -/// `client_info` is populated from TLS and IP metadata available on the -/// request; geo lookup is deferred to handler time via -/// `services.geo().lookup(services.client_info().client_ip)`. -/// -/// `kv_store` is an [`Arc`] opened by the caller for -/// the primary KV store. Use [`open_kv_store`] to construct it. -#[must_use] -pub fn build_runtime_services( - req: &Request, - kv_store: Arc, -) -> RuntimeServices { - RuntimeServices::builder() - .config_store(Arc::new(FastlyPlatformConfigStore)) - .secret_store(Arc::new(FastlyPlatformSecretStore)) - .kv_store(kv_store) - .backend(Arc::new(FastlyPlatformBackend)) - .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) - .client_info(client_info_from_request(req)) - .build() -} - /// Extract [`ClientInfo`] from the original Fastly request. /// /// Fastly's TLS, JA4, and HTTP/2 fingerprint accessors only return real values /// on the client request before it is converted to platform HTTP types. This -/// must therefore be called at the adapter entry point, while the original -/// [`fastly::Request`] is still available. Used by both [`build_runtime_services`] -/// (legacy path) and the `EdgeZero` entry point, which stores the result in the -/// request extensions so `build_per_request_services` can read back the same -/// bot-protection metadata the reconstructed request cannot expose. +/// must therefore be called by the `EdgeZero` entry point while the original +/// [`fastly::Request`] is still available. The result is stored in request +/// extensions so `build_per_request_services` can read back metadata the +/// reconstructed request cannot expose. #[must_use] pub fn client_info_from_request(req: &Request) -> ClientInfo { ClientInfo { @@ -633,18 +599,11 @@ pub fn open_kv_store(store_name: &str) -> Result, KvErr #[cfg(test)] mod tests { use std::io; - use std::sync::Arc; use std::time::Duration; + use super::*; use edgezero_core::body::Body; use edgezero_core::http::request_builder; - use edgezero_core::key_value_store::NoopKvStore; - - use super::*; - - fn noop_kv_store() -> Arc { - Arc::new(NoopKvStore) - } #[test] fn edge_request_to_fastly_replaces_url_derived_host_header() { @@ -771,36 +730,6 @@ mod tests { ); } - // --- ClientInfo extraction ---------------------------------------------- - - #[test] - fn build_runtime_services_client_info_is_none_without_tls() { - let req = Request::get("https://example.com/"); - let services = build_runtime_services(&req, noop_kv_store()); - - assert!( - services.client_info().tls_protocol.is_none(), - "should have no tls_protocol on plain test request" - ); - assert!( - services.client_info().tls_cipher.is_none(), - "should have no tls_cipher on plain test request" - ); - } - - #[test] - fn build_runtime_services_returns_cloneable_services() { - let req = Request::get("https://example.com/"); - let services = build_runtime_services(&req, noop_kv_store()); - let cloned = services.clone(); - - assert_eq!( - services.client_info().client_ip, - cloned.client_info().client_ip, - "should preserve client_ip through clone" - ); - } - // --- FastlyPlatformHttpClient ------------------------------------------- #[test] diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs deleted file mode 100644 index ce542a7c0..000000000 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ /dev/null @@ -1,1973 +0,0 @@ -use std::collections::HashMap; -use std::net::IpAddr; -use std::sync::{Arc, Mutex}; - -use crate::compat; -use bytes::Bytes; -use edgezero_core::body::Body as EdgeBody; -use edgezero_core::http::response_builder as edge_response_builder; -use edgezero_core::key_value_store::NoopKvStore; -use error_stack::Report; -use fastly::http::{header, Method, StatusCode}; -use fastly::Request; -use serde_json::json; -use trusted_server_core::auction::{ - build_orchestrator, AuctionContext, AuctionOrchestrator, AuctionProvider, AuctionRequest, - AuctionResponse, -}; -use trusted_server_core::ec::device::DeviceSignals; -use trusted_server_core::ec::finalize::ec_finalize_response; -use trusted_server_core::ec::registry::PartnerRegistry; -use trusted_server_core::error::TrustedServerError; -use trusted_server_core::integrations::IntegrationRegistry; -use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, - StoreName, -}; -use trusted_server_core::proxy::AssetProxyCachePolicy; -use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME; -use trusted_server_core::settings::{ - AssetImageOptimizerConfig, AssetOriginAuth, ImageOptimizerProfileSet, ImageOptimizerSettings, - ProxyAssetRoute, S3SigV4AuthConfig, Settings, -}; - -use super::{route_request, HandlerOutcome}; - -struct StubJwksConfigStore; - -impl PlatformConfigStore for StubJwksConfigStore { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { - match key { - "active-kids" => Ok("test-kid-1".to_string()), - "test-kid-1" => Ok( - r#"{"kty":"OKP","crv":"Ed25519","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","kid":"test-kid-1","alg":"EdDSA"}"# - .to_string(), - ), - _ => Err(Report::new(PlatformError::ConfigStore)), - } - } - - fn put( - &self, - _store_id: &StoreId, - _key: &str, - _value: &str, - ) -> Result<(), Report> { - Err(Report::new(PlatformError::Unsupported)) - } - - fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::Unsupported)) - } -} - -pub(crate) struct NoopSecretStore; - -struct HashMapSecretStore { - data: HashMap>, -} - -impl HashMapSecretStore { - fn new(data: HashMap>) -> Self { - Self { data } - } -} - -impl PlatformSecretStore for HashMapSecretStore { - fn get_bytes( - &self, - _store_name: &StoreName, - key: &str, - ) -> Result, Report> { - self.data - .get(key) - .cloned() - .ok_or_else(|| Report::new(PlatformError::SecretStore)) - } - - fn create( - &self, - _store_id: &StoreId, - _name: &str, - _value: &str, - ) -> Result<(), Report> { - Err(Report::new(PlatformError::Unsupported)) - } - - fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::Unsupported)) - } -} - -impl PlatformSecretStore for NoopSecretStore { - fn get_bytes( - &self, - _store_name: &StoreName, - _key: &str, - ) -> Result, Report> { - Err(Report::new(PlatformError::Unsupported)) - } - - fn create( - &self, - _store_id: &StoreId, - _name: &str, - _value: &str, - ) -> Result<(), Report> { - Err(Report::new(PlatformError::Unsupported)) - } - - fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::Unsupported)) - } -} - -struct NoopBackend; - -impl PlatformBackend for NoopBackend { - fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } - - fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } -} - -struct NoopHttpClient; - -struct RecordingHttpClient { - calls: Mutex>, - response_status: StatusCode, - response_headers: Vec<(String, String)>, - response_body: Vec, -} - -pub(crate) struct StreamingRecordingHttpClient { - calls: Mutex>, -} - -impl RecordingHttpClient { - fn new(response_status: StatusCode) -> Self { - Self { - calls: Mutex::new(Vec::new()), - response_status, - response_headers: Vec::new(), - response_body: Vec::new(), - } - } - - fn with_response_headers( - mut self, - headers: Vec<(impl Into, impl Into)>, - ) -> Self { - self.response_headers = headers - .into_iter() - .map(|(name, value)| (name.into(), value.into())) - .collect(); - self - } - - fn with_response_body(mut self, body: impl Into>) -> Self { - self.response_body = body.into(); - self - } -} - -impl StreamingRecordingHttpClient { - pub(crate) fn new() -> Self { - Self { - calls: Mutex::new(Vec::new()), - } - } -} - -struct RecordedHttpCall { - method: Method, - uri: String, - backend_name: String, - stream_response: bool, -} - -pub(crate) struct FixedBackend; - -impl PlatformBackend for FixedBackend { - fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { - Ok(format!("{}-{}", spec.scheme, spec.host)) - } - - fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { - self.predict_name(spec) - } -} - -#[async_trait::async_trait(?Send)] -impl PlatformHttpClient for RecordingHttpClient { - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - self.calls - .lock() - .expect("should lock calls") - .push(RecordedHttpCall { - method: request.request.method().clone(), - uri: request.request.uri().to_string(), - backend_name: request.backend_name, - stream_response: request.stream_response, - }); - - let mut builder = edge_response_builder().status(self.response_status); - for (name, value) in &self.response_headers { - builder = builder.header(name, value); - } - let edge_response = builder - .body(EdgeBody::from(self.response_body.clone())) - .map_err(|_| Report::new(PlatformError::HttpClient))?; - - Ok(PlatformResponse::new(edge_response)) - } - - async fn send_async( - &self, - _request: PlatformHttpRequest, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } - - async fn select( - &self, - _pending_requests: Vec, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } -} - -#[async_trait::async_trait(?Send)] -impl PlatformHttpClient for StreamingRecordingHttpClient { - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - self.calls - .lock() - .expect("should lock calls") - .push(RecordedHttpCall { - method: request.request.method().clone(), - uri: request.request.uri().to_string(), - backend_name: request.backend_name, - stream_response: request.stream_response, - }); - - let edge_response = edge_response_builder() - .status(StatusCode::OK) - .body(EdgeBody::stream(futures::stream::iter(vec![ - Bytes::from_static(b"streamed-asset"), - ]))) - .map_err(|_| Report::new(PlatformError::HttpClient))?; - - Ok(PlatformResponse::new(edge_response)) - } - - async fn send_async( - &self, - _request: PlatformHttpRequest, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } - - async fn select( - &self, - _pending_requests: Vec, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } -} - -#[async_trait::async_trait(?Send)] -impl PlatformHttpClient for NoopHttpClient { - async fn send( - &self, - _request: PlatformHttpRequest, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } - - async fn send_async( - &self, - _request: PlatformHttpRequest, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } - - async fn select( - &self, - _pending_requests: Vec, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } -} - -struct NoopGeo; - -impl PlatformGeo for NoopGeo { - fn lookup(&self, _client_ip: Option) -> Result, Report> { - Ok(None) - } -} - -struct DisabledRouteProvider; - -#[async_trait::async_trait(?Send)] -impl AuctionProvider for DisabledRouteProvider { - fn provider_name(&self) -> &'static str { - "disabled-route" - } - - async fn request_bids( - &self, - _request: &AuctionRequest, - _context: &AuctionContext<'_>, - ) -> Result> { - Err(Report::new(TrustedServerError::Auction { - message: "disabled route provider should not launch requests".to_string(), - })) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - _response_time_ms: u64, - ) -> Result> { - Err(Report::new(TrustedServerError::Auction { - message: "disabled route provider should not parse responses".to_string(), - })) - } - - fn timeout_ms(&self) -> u32 { - 2000 - } - - fn is_enabled(&self) -> bool { - false - } -} - -pub(crate) struct FixedGeo(pub(crate) GeoInfo); - -impl PlatformGeo for FixedGeo { - fn lookup(&self, _client_ip: Option) -> Result, Report> { - Ok(Some(self.0.clone())) - } -} - -pub(crate) fn us_california_geo() -> GeoInfo { - GeoInfo { - city: "Example City".to_string(), - country: "US".to_string(), - continent: "NA".to_string(), - latitude: 37.0, - longitude: -122.0, - metro_code: 807, - region: Some("CA".to_string()), - asn: None, - } -} - -fn valid_ec_id() -> String { - format!("{}.Abc123", "a".repeat(64)) -} - -fn base_route_settings_toml() -> &'static str { - r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - - [publisher] - domain = "test-publisher.com" - cookie_domain = ".test-publisher.com" - origin_url = "https://origin.test-publisher.com" - proxy_secret = "unit-test-proxy-secret" - - [ec] - passphrase = "test-secret-key-32-bytes-minimum" - - [request_signing] - enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" - "# -} - -fn prebid_integration_toml() -> &'static str { - r#" - [integrations.prebid] - enabled = true - server_url = "https://test-prebid.com/openrtb2/auction" - "# -} - -fn create_test_settings() -> Settings { - let base = base_route_settings_toml(); - let prebid = prebid_integration_toml(); - let config = format!( - r#"{base} - -{prebid} - - [auction] - enabled = true - providers = ["prebid"] - timeout_ms = 2000 - "#, - ); - let settings = Settings::from_toml(&config).expect("should parse adapter route test settings"); - - assert_eq!( - JWKS_CONFIG_STORE_NAME, "jwks_store", - "should keep the stub discovery store aligned with the production constant" - ); - - settings -} - -fn create_auction_test_settings(providers: &str) -> Settings { - let base = base_route_settings_toml(); - let prebid = prebid_integration_toml(); - let config = format!( - r#"{base} - -{prebid} - - [auction] - enabled = true - providers = {providers} - timeout_ms = 2000 - "#, - ); - - Settings::from_toml(&config).expect("should parse adapter auction route test settings") -} - -fn datadome_protection_toml() -> &'static str { - r#" - [integrations.datadome] - enabled = true - enable_protection = true - server_side_key_secret_store = "ts_secrets" - server_side_key_secret_name = "datadome_server_side_key" - "# -} - -fn create_datadome_auction_test_settings(providers: &str) -> Settings { - let base = base_route_settings_toml(); - let datadome = datadome_protection_toml(); - let config = format!( - r#"{base} - -{datadome} - - [auction] - enabled = true - providers = {providers} - timeout_ms = 2000 - "#, - ); - - Settings::from_toml(&config).expect("should parse DataDome route test settings") -} - -fn datadome_secret_store() -> Arc { - Arc::new(HashMapSecretStore::new(HashMap::from([( - "datadome_server_side_key".to_string(), - b"datadome-server-side-key".to_vec(), - )]))) -} - -fn build_route_stack(settings: &Settings) -> (AuctionOrchestrator, IntegrationRegistry) { - let orchestrator = build_orchestrator(settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(settings).expect("should create integration registry"); - - (orchestrator, integration_registry) -} - -fn test_runtime_services(req: &Request) -> RuntimeServices { - test_runtime_services_with_http_client( - req, - Arc::new(NoopBackend), - Arc::new(NoopHttpClient) as Arc, - ) -} - -fn test_runtime_services_with_http_client( - req: &Request, - backend: Arc, - http_client: Arc, -) -> RuntimeServices { - test_runtime_services_with_secret_and_http_client( - req, - backend, - Arc::new(NoopSecretStore), - http_client, - ) -} - -fn test_runtime_services_with_secret_and_http_client( - req: &Request, - backend: Arc, - secret_store: Arc, - http_client: Arc, -) -> RuntimeServices { - test_runtime_services_with_secret_http_client_and_geo( - req, - backend, - secret_store, - http_client, - Arc::new(NoopGeo), - ) -} - -pub(crate) fn test_runtime_services_with_secret_http_client_and_geo( - req: &Request, - backend: Arc, - secret_store: Arc, - http_client: Arc, - geo: Arc, -) -> RuntimeServices { - RuntimeServices::builder() - .config_store(Arc::new(StubJwksConfigStore)) - .secret_store(secret_store) - .kv_store(Arc::new(NoopKvStore) as Arc) - .backend(backend) - .http_client(http_client) - .geo(geo) - .client_info(ClientInfo { - client_ip: req.get_client_ip_addr(), - tls_protocol: req.get_tls_protocol().ok().flatten().map(str::to_string), - tls_cipher: req - .get_tls_cipher_openssl_name() - .ok() - .flatten() - .map(str::to_string), - tls_ja4: req.get_tls_ja4().map(str::to_string), - h2_fingerprint: req.get_client_h2_fingerprint().map(str::to_string), - server_hostname: None, - server_region: None, - }) - .build() -} - -fn test_partner_registry(settings: &Settings) -> PartnerRegistry { - PartnerRegistry::from_config(&settings.ec.partners).expect("should build partner registry") -} - -fn route_result_to_fastly_response( - settings: &Settings, - services: &RuntimeServices, - partner_registry: &PartnerRegistry, - route_result: super::RouteResult, -) -> fastly::Response { - let super::RouteResult { - outcome, - ec_context, - finalize_kv_graph, - eids_cookie, - sharedid_cookie, - should_finalize_ec, - asset_cache_policy, - request_filter_effects, - .. - } = route_result; - - let is_auth_challenge = matches!(&outcome, HandlerOutcome::AuthChallenge(_)); - let mut response = match outcome { - HandlerOutcome::Buffered(response) | HandlerOutcome::AuthChallenge(response) => { - Some(response) - } - _ => None, - } - .expect("should have a buffered route response"); - - let geo_info = if is_auth_challenge { - None - } else { - services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or(None) - }; - super::finalize_response(settings, geo_info.as_ref(), &mut response); - asset_cache_policy.apply_after_route_finalization(&mut response); - - if should_finalize_ec { - ec_finalize_response( - settings, - &ec_context, - finalize_kv_graph.as_ref(), - partner_registry, - eids_cookie.as_deref(), - sharedid_cookie.as_deref(), - &mut response, - ); - } - request_filter_effects.apply_to_response(&mut response); - compat::to_fastly_response(response) -} - -fn browser_device_signals() -> DeviceSignals { - DeviceSignals::derive( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", - Some("t13d1516h2_000000000000_000000000000"), - None, - ) -} - -fn route_auction(settings: &Settings, body: impl Into>) -> fastly::Response { - let (orchestrator, integration_registry) = build_route_stack(settings); - - route_auction_with_stack(settings, &orchestrator, &integration_registry, body) -} - -fn route_auction_with_stack( - settings: &Settings, - orchestrator: &AuctionOrchestrator, - integration_registry: &IntegrationRegistry, - body: impl Into>, -) -> fastly::Response { - let partner_registry = - PartnerRegistry::from_config(&settings.ec.partners).expect("should build partner registry"); - let req = Request::post("https://test.com/auction") - .with_header(header::CONTENT_TYPE, "application/json") - .with_body(body.into()); - let services = test_runtime_services(&req); - - let route_result = futures::executor::block_on(route_request( - settings, - orchestrator, - integration_registry, - &partner_registry, - &services, - compat::from_fastly_request(req), - browser_device_signals(), - )) - .expect("should route auction request"); - route_result_to_fastly_response(settings, &services, &partner_registry, route_result) -} - -fn route_buffered_response( - settings: &Settings, - orchestrator: &AuctionOrchestrator, - integration_registry: &IntegrationRegistry, - services: &RuntimeServices, - req: Request, - expect_message: &str, -) -> fastly::Response { - let partner_registry = - PartnerRegistry::from_config(&settings.ec.partners).expect("should build partner registry"); - - let route_result = futures::executor::block_on(route_request( - settings, - orchestrator, - integration_registry, - &partner_registry, - services, - compat::from_fastly_request(req), - browser_device_signals(), - )) - .expect(expect_message); - route_result_to_fastly_response(settings, services, &partner_registry, route_result) -} - -fn route_with_settings( - settings: &Settings, - req: Request, - expect_message: &str, -) -> fastly::Response { - let (orchestrator, integration_registry) = build_route_stack(settings); - let services = test_runtime_services(&req); - - route_buffered_response( - settings, - &orchestrator, - &integration_registry, - &services, - req, - expect_message, - ) -} - -fn valid_banner_ad_unit_body() -> Vec { - serde_json::to_vec(&json!({ - "adUnits": [ - { - "code": "div-gpt-ad-1", - "mediaTypes": { - "banner": { - "sizes": [[300, 250]] - } - } - } - ] - })) - .expect("should serialize valid auction route test body") -} - -#[test] -fn static_tsjs_route_serves_unified_bundle() { - let settings = create_test_settings(); - let req = Request::get("https://test.com/static/tsjs=tsjs-unified.min.js"); - - let mut response = route_with_settings(&settings, req, "should route static tsjs request"); - - assert_eq!( - response.get_status(), - StatusCode::OK, - "should serve the unified static bundle" - ); - assert_eq!( - response.get_header_str(header::CONTENT_TYPE), - Some("application/javascript; charset=utf-8"), - "should serve the unified bundle as JavaScript" - ); - assert!( - response.take_body_str().contains("requestAds"), - "should serve unified bundle content with the core requestAds API" - ); -} - -#[test] -fn static_tsjs_route_returns_not_found_for_unknown_tsjs_bundle() { - let settings = create_test_settings(); - let req = Request::get("https://test.com/static/tsjs=tsjs-doesnotexist.min.js"); - - let response = route_with_settings(&settings, req, "should route static tsjs request"); - - assert_eq!( - response.get_status(), - StatusCode::NOT_FOUND, - "should let the static tsjs branch own unknown bundle paths" - ); -} - -#[test] -fn unknown_route_falls_back_to_publisher_proxy_path() { - let settings = create_test_settings(); - let (orchestrator, integration_registry) = build_route_stack(&settings); - - let req = Request::get("https://test.com/articles/example"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::BAD_GATEWAY)); - let services = test_runtime_services_with_http_client( - &req, - Arc::new(FixedBackend), - Arc::clone(&http_client) as Arc, - ); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route publisher fallback", - ); - - assert_eq!( - response.get_status(), - StatusCode::BAD_GATEWAY, - "should return the publisher origin response through fallback routing" - ); - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!( - calls.len(), - 1, - "should send exactly one publisher fallback request" - ); - assert_eq!( - calls[0].method, - Method::GET, - "should preserve the fallback request method" - ); - assert_eq!( - calls[0].backend_name, "https-origin.test-publisher.com", - "should dispatch to the publisher origin backend" - ); - assert_eq!( - calls[0].uri, "https://origin.test-publisher.com/articles/example", - "should send the fallback request to the publisher origin" - ); -} - -#[test] -fn datadome_challenge_short_circuits_before_publisher_origin() { - let settings = create_datadome_auction_test_settings("[]"); - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::get("https://test.com/protected-page"); - let http_client = Arc::new( - RecordingHttpClient::new(StatusCode::FORBIDDEN) - .with_response_headers(vec![ - ("x-datadomeresponse", "403"), - ("x-datadome-headers", "Set-Cookie X-DD-B"), - ("set-cookie", "datadome=challenge; Path=/; HttpOnly"), - ("x-dd-b", "1"), - ]) - .with_response_body(b"blocked by datadome".to_vec()), - ); - let services = test_runtime_services_with_secret_and_http_client( - &req, - Arc::new(FixedBackend), - datadome_secret_store(), - Arc::clone(&http_client) as Arc, - ); - - let mut response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route DataDome challenge response", - ); - - assert_eq!( - response.get_status(), - StatusCode::FORBIDDEN, - "should return the DataDome challenge status instead of contacting publisher origin" - ); - assert_eq!( - response.get_header_str("x-dd-b"), - Some("1"), - "should apply DataDome downstream challenge headers" - ); - assert_eq!( - response.get_header_str(header::SET_COOKIE), - Some("datadome=challenge; Path=/; HttpOnly"), - "should append the DataDome challenge cookie" - ); - assert_eq!( - response.take_body_str(), - "blocked by datadome", - "should return the DataDome challenge body" - ); - - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!(calls.len(), 1, "should call only the Protection API"); - assert_eq!(calls[0].method, Method::POST, "should POST to DataDome"); - assert_eq!( - calls[0].uri, "https://api-fastly.datadome.co/validate-request", - "should call the default DataDome Protection API endpoint" - ); -} - -#[test] -fn datadome_allow_applies_downstream_headers_and_protects_auction() { - let settings = create_datadome_auction_test_settings("[]"); - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::post("https://test.com/auction") - .with_header(header::CONTENT_TYPE, "application/json") - .with_body(valid_banner_ad_unit_body()); - let http_client = Arc::new( - RecordingHttpClient::new(StatusCode::OK).with_response_headers(vec![ - ("x-datadomeresponse", "200"), - ("x-datadome-headers", "Set-Cookie X-DD-B"), - ("set-cookie", "datadome=allow; Path=/; HttpOnly"), - ("x-dd-b", "allowed"), - ]), - ); - let services = test_runtime_services_with_secret_and_http_client( - &req, - Arc::new(FixedBackend), - datadome_secret_store(), - Arc::clone(&http_client) as Arc, - ); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route DataDome-allowed auction request", - ); - - assert_eq!( - response.get_status(), - StatusCode::BAD_GATEWAY, - "empty-provider auction should still run after DataDome allows the request" - ); - assert_eq!( - response.get_header_str("x-dd-b"), - Some("allowed"), - "should apply DataDome downstream headers after route finalization" - ); - assert_eq!( - response.get_header_str(header::SET_COOKIE), - Some("datadome=allow; Path=/; HttpOnly"), - "should preserve DataDome downstream Set-Cookie on allowed requests" - ); - - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!( - calls.len(), - 1, - "should protect /auction through DataDome by default" - ); - assert_eq!(calls[0].method, Method::POST, "should POST to DataDome"); -} - -#[test] -fn datadome_api_error_fails_open_before_routing() { - let settings = create_datadome_auction_test_settings("[]"); - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::post("https://test.com/auction") - .with_header(header::CONTENT_TYPE, "application/json") - .with_body(b"{not-json".to_vec()); - let services = test_runtime_services_with_secret_and_http_client( - &req, - Arc::new(FixedBackend), - datadome_secret_store(), - Arc::new(NoopHttpClient) as Arc, - ); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should fail open when DataDome API call fails", - ); - - assert_eq!( - response.get_status(), - StatusCode::BAD_REQUEST, - "malformed auction JSON should be handled by the route after DataDome fails open" - ); - assert_eq!( - response.get_header_str("x-dd-b"), - None, - "should not apply DataDome headers when the Protection API call fails" - ); -} - -#[test] -fn datadome_skips_internal_and_static_asset_routes_by_default() { - let mut settings = create_datadome_auction_test_settings("[]"); - settings.publisher.origin_url = "https://".to_string(); - let (orchestrator, integration_registry) = build_route_stack(&settings); - let http_client = Arc::new( - RecordingHttpClient::new(StatusCode::OK).with_response_headers(vec![ - ("x-datadomeresponse", "200"), - ("x-datadome-headers", "X-DD-B"), - ("x-dd-b", "should-not-apply"), - ]), - ); - - let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); - let discovery_services = test_runtime_services_with_secret_and_http_client( - &discovery_req, - Arc::new(FixedBackend), - datadome_secret_store(), - Arc::clone(&http_client) as Arc, - ); - let discovery_response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &discovery_services, - discovery_req, - "should route internal discovery request without DataDome", - ); - assert_eq!( - discovery_response.get_status(), - StatusCode::OK, - "discovery endpoint should stay internal" - ); - - let image_req = Request::get("https://test.com/logo.png"); - let image_services = test_runtime_services_with_secret_and_http_client( - &image_req, - Arc::new(FixedBackend), - datadome_secret_store(), - Arc::clone(&http_client) as Arc, - ); - let image_response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &image_services, - image_req, - "should route static asset request without DataDome", - ); - assert_eq!( - image_response.get_status(), - StatusCode::BAD_GATEWAY, - "static asset should skip DataDome then fail at the intentionally invalid publisher origin" - ); - - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert!( - calls.is_empty(), - "should not call DataDome for internal routes or default-excluded static assets" - ); -} - -#[test] -fn datadome_skips_registered_integration_routes_with_custom_prefix() { - let base = base_route_settings_toml(); - let datadome = datadome_protection_toml(); - let config = format!( - r#"{base} - -{datadome} - - [integrations.didomi] - enabled = true - proxy_path = "my-consent" - sdk_origin = "https://sdk.privacy-center.org" - api_origin = "https://api.privacy-center.org" - - [auction] - enabled = true - providers = [] - timeout_ms = 2000 - "#, - ); - let settings = Settings::from_toml(&config) - .expect("should parse DataDome and custom Didomi route test settings"); - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::get("https://test.com/my-consent/notice"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); - let services = test_runtime_services_with_secret_and_http_client( - &req, - Arc::new(FixedBackend), - datadome_secret_store(), - Arc::clone(&http_client) as Arc, - ); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route custom Didomi proxy request without DataDome", - ); - - assert_eq!( - response.get_status(), - StatusCode::OK, - "custom integration proxy route should still be handled" - ); - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!(calls.len(), 1, "should call only the Didomi upstream"); - assert_eq!( - calls[0].uri, "https://sdk.privacy-center.org/notice", - "should not call the DataDome Protection API for registered integration routes" - ); -} - -#[test] -fn routes_use_request_local_consent() { - let settings = create_test_settings(); - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let partner_registry = test_partner_registry(&settings); - - let discovery_fastly_req = Request::get("https://test.com/.well-known/trusted-server.json"); - let discovery_services = test_runtime_services(&discovery_fastly_req); - let discovery_resp = futures::executor::block_on(route_request( - &settings, - &orchestrator, - &integration_registry, - &partner_registry, - &discovery_services, - compat::from_fastly_request(discovery_fastly_req), - DeviceSignals::derive("", None, None), - )) - .expect("should route discovery request"); - assert_eq!( - discovery_resp.outcome.status(), - StatusCode::OK, - "should keep discovery available with request-local consent" - ); - - let admin_fastly_req = Request::post("https://test.com/_ts/admin/keys/rotate"); - let admin_services = test_runtime_services(&admin_fastly_req); - let admin_resp = futures::executor::block_on(route_request( - &settings, - &orchestrator, - &integration_registry, - &partner_registry, - &admin_services, - compat::from_fastly_request(admin_fastly_req), - DeviceSignals::derive("", None, None), - )) - .expect("should route admin request"); - assert_eq!( - admin_resp.outcome.status(), - StatusCode::UNAUTHORIZED, - "should keep admin auth behavior unchanged with request-local consent" - ); - - // Routes no longer depend on a separate consent KV store. Live consent is - // request-local, and EC lifecycle state uses the EC identity store only. -} - -#[test] -fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { - // Regression for the credential-leak finding on the legacy route_request - // path: the production basic-auth regex `^/_ts/admin` does not match - // `/admin/keys/*`, so those aliases are not auth-gated. They must be denied - // locally with 404 rather than fall through to the publisher fallback, which - // forwards the request — including the `Authorization` header and key body — - // to the origin, leaking admin credentials. A 404 (not a 5xx proxy error) - // proves the local deny arm ran instead of the publisher fallback. - let settings = create_test_settings(); - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let partner_registry = test_partner_registry(&settings); - - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { - for method in [Method::POST, Method::GET] { - let alias_req = if method == Method::POST { - Request::post(format!("https://test.com{path}")) - .with_header("Authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .with_body("{\"key_id\":\"leak-me\"}") - } else { - Request::get(format!("https://test.com{path}")) - .with_header("Authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - }; - let services = test_runtime_services(&alias_req); - let resp = futures::executor::block_on(route_request( - &settings, - &orchestrator, - &integration_registry, - &partner_registry, - &services, - compat::from_fastly_request(alias_req), - DeviceSignals::derive("", None, None), - )) - .expect("should route legacy admin alias request"); - - assert_eq!( - resp.outcome.status(), - StatusCode::NOT_FOUND, - "{method} {path} with Authorization must be denied locally (404), not proxied to publisher" - ); - } - } -} - -#[test] -fn set_tester_route_is_disabled_by_default() { - let settings = create_test_settings(); - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::get("https://test.com/_ts/set-tester"); - let services = test_runtime_services(&req); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route disabled tester-cookie request", - ); - - assert_eq!( - response.get_status(), - StatusCode::NOT_FOUND, - "disabled tester-cookie route should return 404" - ); - assert_eq!( - response.get_header_str(header::SET_COOKIE), - None, - "disabled tester-cookie route should not set a cookie" - ); -} - -#[test] -fn set_tester_route_sets_cookie_on_configured_domain_when_enabled() { - let mut settings = create_test_settings(); - settings.tester_cookie.enabled = true; - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::get("https://test.com/_ts/set-tester"); - let services = test_runtime_services(&req); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route enabled tester-cookie request", - ); - - assert_eq!( - response.get_status(), - StatusCode::NO_CONTENT, - "enabled tester-cookie route should return no content" - ); - assert_eq!( - response.get_header_str(header::SET_COOKIE), - Some("ts-tester=true; Domain=.test-publisher.com; Path=/; Secure; SameSite=Lax"), - "tester cookie should use publisher.cookie_domain" - ); -} - -#[test] -fn clear_tester_route_is_disabled_by_default() { - let settings = create_test_settings(); - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::get("https://test.com/_ts/clear-tester"); - let services = test_runtime_services(&req); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route disabled clear tester-cookie request", - ); - - assert_eq!( - response.get_status(), - StatusCode::NOT_FOUND, - "disabled clear tester-cookie route should return 404" - ); - assert_eq!( - response.get_header_str(header::SET_COOKIE), - None, - "disabled clear tester-cookie route should not set a cookie" - ); -} - -#[test] -fn clear_tester_route_clears_cookie_on_configured_domain_when_enabled() { - let mut settings = create_test_settings(); - settings.tester_cookie.enabled = true; - let (orchestrator, integration_registry) = build_route_stack(&settings); - let req = Request::get("https://test.com/_ts/clear-tester"); - let services = test_runtime_services(&req); - - let response = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route enabled clear tester-cookie request", - ); - - assert_eq!( - response.get_status(), - StatusCode::NO_CONTENT, - "enabled clear tester-cookie route should return no content" - ); - assert_eq!( - response.get_header_str(header::SET_COOKIE), - Some("ts-tester=; Domain=.test-publisher.com; Path=/; Secure; SameSite=Lax; Max-Age=0"), - "tester cookie clear should use publisher.cookie_domain" - ); -} - -#[test] -fn malformed_auction_json_returns_bad_request() { - let settings = create_auction_test_settings(r#"["prebid"]"#); - - let mut response = route_auction(&settings, "{not-json"); - - assert_eq!( - response.get_status(), - StatusCode::BAD_REQUEST, - "should reject malformed JSON as a client request error" - ); - assert!( - response.take_body_str().contains("Bad request"), - "should return a client-facing bad request message" - ); -} - -#[test] -fn invalid_auction_banner_size_returns_bad_request() { - let settings = create_auction_test_settings(r#"["prebid"]"#); - let body = serde_json::to_vec(&json!({ - "adUnits": [ - { - "code": "div-gpt-ad-1", - "mediaTypes": { - "banner": { - "sizes": [[300]] - } - } - } - ] - })) - .expect("should serialize invalid auction route test body"); - - let response = route_auction(&settings, body); - - assert_eq!( - response.get_status(), - StatusCode::BAD_REQUEST, - "should reject semantically invalid banner sizes as a client request error" - ); -} - -#[test] -fn auction_request_with_empty_provider_list_returns_bad_gateway() { - let settings = create_auction_test_settings("[]"); - - let response = route_auction(&settings, valid_banner_ad_unit_body()); - - assert_eq!( - response.get_status(), - StatusCode::BAD_GATEWAY, - "should surface no-provider orchestration failures as gateway errors" - ); -} - -#[test] -fn auction_request_with_disabled_provider_returns_bad_gateway() { - let settings = create_auction_test_settings(r#"["disabled-route"]"#); - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(DisabledRouteProvider)); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let response = route_auction_with_stack( - &settings, - &orchestrator, - &integration_registry, - valid_banner_ad_unit_body(), - ); - - assert_eq!( - response.get_status(), - StatusCode::BAD_GATEWAY, - "should map skipped-provider launch failures to gateway errors" - ); -} - -#[test] -fn asset_routes_bypass_publisher_consent_dependencies() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let asset_req = Request::get("https://test.com/.images/logo.png?auto=webp"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::ACCEPTED)); - let asset_services = test_runtime_services_with_http_client( - &asset_req, - Arc::new(FixedBackend), - Arc::clone(&http_client) as Arc, - ); - let asset_resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &asset_services, - asset_req, - "should route asset proxy request", - ); - - assert_eq!( - asset_resp.get_status(), - StatusCode::ACCEPTED, - "should return the asset-origin response without publisher consent dependencies" - ); - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!(calls.len(), 1, "should send exactly one asset request"); - assert_eq!( - calls[0].backend_name, "https-assets.example.com", - "should resolve the configured asset backend, not the publisher origin" - ); - assert_eq!( - calls[0].uri, "https://assets.example.com/.images/logo.png?auto=webp", - "should send the request to the configured asset origin" - ); -} - -#[test] -fn asset_routes_skip_ec_finalization_cookie_mutations() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let mut req = Request::get("https://test.com/.images/logo.png"); - req.set_header(header::COOKIE, format!("ts-ec={}", valid_ec_id())); - req.set_header("sec-gpc", "1"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); - let services = test_runtime_services_with_secret_http_client_and_geo( - &req, - Arc::new(FixedBackend), - Arc::new(NoopSecretStore), - Arc::clone(&http_client) as Arc, - Arc::new(FixedGeo(us_california_geo())), - ); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route asset request without EC finalization", - ); - - assert_eq!( - resp.get_status(), - StatusCode::OK, - "should return the asset-origin response" - ); - assert_eq!( - resp.get_header_str(header::SET_COOKIE), - None, - "should not expire or set EC cookies on asset responses" - ); - assert_eq!( - resp.get_header_str("x-ts-ec"), - None, - "should not emit EC identity headers on asset responses" - ); -} - -#[test] -fn asset_routes_stream_asset_responses_directly() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let partner_registry = test_partner_registry(&settings); - - let mut fastly_req = Request::get("https://test.com/.images/logo.png"); - fastly_req.set_header(header::COOKIE, format!("ts-ec={}", valid_ec_id())); - fastly_req.set_header("sec-gpc", "1"); - let http_client = Arc::new(StreamingRecordingHttpClient::new()); - let services = test_runtime_services_with_secret_http_client_and_geo( - &fastly_req, - Arc::new(FixedBackend), - Arc::new(NoopSecretStore), - Arc::clone(&http_client) as Arc, - Arc::new(FixedGeo(us_california_geo())), - ); - let req = compat::from_fastly_request(fastly_req); - - let outcome = futures::executor::block_on(route_request( - &settings, - &orchestrator, - &integration_registry, - &partner_registry, - &services, - req, - browser_device_signals(), - )) - .expect("should route streaming asset request"); - - assert!( - !outcome.should_finalize_ec, - "asset routes should not emit EC identity headers" - ); - assert_eq!( - outcome.asset_cache_policy, - AssetProxyCachePolicy::OriginControlled, - "successful asset routes should preserve origin cache policy" - ); - let (response, body) = match outcome.outcome { - HandlerOutcome::AssetStreaming { response, body } => Some((response, body)), - _ => None, - } - .expect("should return streaming asset outcome"); - assert_eq!( - response.status(), - StatusCode::OK, - "should preserve streaming asset response status" - ); - assert!( - matches!(body, EdgeBody::Stream(_)), - "should preserve streaming asset response body" - ); - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!(calls.len(), 1, "should send exactly one asset request"); - assert!( - calls[0].stream_response, - "asset routes should request a streaming origin response from the platform" - ); - assert_eq!( - calls[0].backend_name, "https-assets.example.com", - "should resolve the configured asset backend, not the publisher origin" - ); - assert_eq!( - calls[0].uri, "https://assets.example.com/.images/logo.png", - "should send the request to the configured asset origin" - ); - // `stream_to_client` commits headers and bytes into the Fastly host runtime, - // leaving no buffered `Response` for this route-level test to inspect. Core - // proxy tests assert unsafe header stripping on the real streaming body; this - // test pins the adapter contract that successful streaming asset responses - // take the `response: None` direct-send path with EC finalization skipped. -} - -#[test] -fn asset_origin_failure_does_not_fall_back_to_publisher_origin() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::get("https://test.com/.images/logo.png"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); - let services = test_runtime_services_with_http_client( - &req, - Arc::new(NoopBackend), - Arc::clone(&http_client) as Arc, - ); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should return an error response for failed asset origin", - ); - - assert_eq!( - resp.get_status(), - StatusCode::BAD_GATEWAY, - "should stop asset-origin backend failures at the asset proxy path" - ); - assert!( - http_client - .calls - .lock() - .expect("should lock recorded calls") - .is_empty(), - "should not invoke the publisher origin when asset backend registration fails" - ); -} - -#[test] -fn asset_handler_error_stays_uncacheable_after_global_headers() { - let mut settings = create_test_settings(); - settings.response_headers.insert( - header::CACHE_CONTROL.as_str().to_string(), - "public, max-age=3600".to_string(), - ); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::get("https://test.com/.images/logo.png"); - let services = test_runtime_services(&req); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route generated asset error response", - ); - - assert_eq!( - resp.get_status(), - StatusCode::BAD_GATEWAY, - "should return the generated asset proxy error" - ); - assert_eq!( - resp.get_header_str(header::CACHE_CONTROL), - Some("no-store, private"), - "should not let global cache headers make generated asset errors cacheable" - ); -} - -#[test] -fn s3_asset_origin_error_stays_uncacheable_after_global_headers() { - let mut settings = create_test_settings(); - settings.response_headers.insert( - header::CACHE_CONTROL.as_str().to_string(), - "public, max-age=3600".to_string(), - ); - settings.image_optimizer = ImageOptimizerSettings { - profile_sets: HashMap::from([( - "default_images".to_string(), - ImageOptimizerProfileSet { - base_params: String::new(), - default_profile: "default".to_string(), - unknown_profile: Default::default(), - profile_param: "profile".to_string(), - aspect_ratio_param: "ar".to_string(), - debug_param: "_io_debug".to_string(), - profiles: HashMap::from([("default".to_string(), "width=100".to_string())]), - aspect_ratios: None, - crop_offsets: None, - }, - )]), - }; - let mut route = ProxyAssetRoute::new( - "/.images/", - "https://examplebucket.s3.us-east-1.amazonaws.com", - ); - route.auth = Some(AssetOriginAuth::S3SigV4(S3SigV4AuthConfig { - region: "us-east-1".to_string(), - secret_store: "s3-auth".to_string(), - access_key_id: "access_key_id".to_string(), - secret_access_key: "secret_access_key".to_string(), - session_token: None, - origin_query: None, - })); - route.image_optimizer = Some(AssetImageOptimizerConfig { - enabled: true, - region: "us_east".to_string(), - profile_set: "default_images".to_string(), - origin_query: None, - }); - settings.proxy.asset_routes = vec![route]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::get("https://test.com/.images/missing.png?profile=default"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::NOT_FOUND)); - let services = test_runtime_services_with_secret_and_http_client( - &req, - Arc::new(FixedBackend), - Arc::new(HashMapSecretStore::new(HashMap::from([ - ( - "access_key_id".to_string(), - b"AKIAIOSFODNN7EXAMPLE".to_vec(), - ), - ( - "secret_access_key".to_string(), - b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(), - ), - ]))), - Arc::clone(&http_client) as Arc, - ); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route S3 asset request", - ); - - assert_eq!( - resp.get_status(), - StatusCode::NOT_FOUND, - "should return the raw S3 origin error status" - ); - assert_eq!( - resp.get_header_str(header::CACHE_CONTROL), - Some("no-store, private"), - "should preserve the S3 origin error no-store policy after global headers" - ); - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!( - calls.len(), - 2, - "should preflight with HEAD and then fetch the S3 error body" - ); -} - -#[test] -fn asset_routes_proxy_head_requests() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::head("https://test.com/.images/logo.png"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::NO_CONTENT)); - let services = test_runtime_services_with_http_client( - &req, - Arc::new(FixedBackend), - Arc::clone(&http_client) as Arc, - ); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route HEAD asset request", - ); - - assert_eq!( - resp.get_status(), - StatusCode::NO_CONTENT, - "should pass through asset-origin HEAD response status" - ); - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!(calls.len(), 1, "should send exactly one asset request"); - assert_eq!( - calls[0].method, - Method::HEAD, - "should forward HEAD upstream" - ); - assert!( - calls[0].backend_name.contains("assets.example.com"), - "should send to the asset backend, got {}", - calls[0].backend_name - ); -} - -#[test] -fn asset_routes_ignore_query_string_for_matching() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::get("https://test.com/.images/logo.png?auto=webp"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); - let services = test_runtime_services_with_http_client( - &req, - Arc::new(FixedBackend), - Arc::clone(&http_client) as Arc, - ); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route asset request with query string", - ); - - assert_eq!( - resp.get_status(), - StatusCode::OK, - "should match by path only" - ); - let calls = http_client - .calls - .lock() - .expect("should lock recorded calls"); - assert_eq!(calls.len(), 1, "should send exactly one asset request"); - assert!( - calls[0].uri.ends_with("/.images/logo.png?auto=webp"), - "should preserve query on the upstream asset request, got {}", - calls[0].uri - ); -} - -#[test] -fn asset_routes_pass_redirect_responses_through() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::get("https://test.com/.images/logo.png"); - let http_client = Arc::new( - RecordingHttpClient::new(StatusCode::FOUND).with_response_headers(vec![( - header::LOCATION.as_str(), - "https://cdn.example.com/logo.png", - )]), - ); - let services = test_runtime_services_with_http_client( - &req, - Arc::new(FixedBackend), - Arc::clone(&http_client) as Arc, - ); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route redirecting asset request", - ); - - assert_eq!( - resp.get_status(), - StatusCode::FOUND, - "should pass redirect status through without following it" - ); - assert_eq!( - resp.get_header_str(header::LOCATION), - Some("https://cdn.example.com/logo.png"), - "should preserve asset-origin redirect location" - ); -} - -#[test] -fn asset_routes_skip_non_get_head_requests() { - let mut settings = create_test_settings(); - settings.publisher.origin_url = "https://".to_string(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.images/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::post("https://test.com/.images/logo.png"); - let http_client = Arc::new(RecordingHttpClient::new(StatusCode::OK)); - let services = test_runtime_services_with_http_client( - &req, - Arc::new(FixedBackend), - Arc::clone(&http_client) as Arc, - ); - - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route non-asset POST request", - ); - - assert_eq!( - resp.get_status(), - StatusCode::BAD_GATEWAY, - "should fall through to publisher fallback for POST requests" - ); - assert!( - http_client - .calls - .lock() - .expect("should lock recorded calls") - .is_empty(), - "should not send POST requests through asset routing" - ); -} - -#[test] -fn built_in_routes_take_precedence_over_asset_routes() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/.well-known/", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::get("https://test.com/.well-known/trusted-server.json"); - let services = test_runtime_services(&req); - let resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route discovery request", - ); - assert_eq!( - resp.get_status(), - StatusCode::OK, - "should keep explicit built-in routes ahead of asset routes" - ); -} - -#[test] -fn integration_routes_take_precedence_over_asset_routes() { - let mut settings = create_test_settings(); - settings.proxy.asset_routes = vec![ProxyAssetRoute::new( - "/prebid.js", - "https://assets.example.com", - )]; - let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); - let integration_registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - - let req = Request::get("https://test.com/prebid.js"); - let services = test_runtime_services(&req); - let mut resp = route_buffered_response( - &settings, - &orchestrator, - &integration_registry, - &services, - req, - "should route integration request", - ); - assert_eq!( - resp.get_status(), - StatusCode::OK, - "should keep explicit integration routes ahead of asset routes" - ); - assert_eq!( - resp.take_body_str(), - "// Script overridden by Trusted Server\n", - "should serve the integration response instead of proxying to the asset origin" - ); -} diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 0dddd7a1d..3d2890891 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -22,8 +22,7 @@ use trusted_server_core::publisher::{ PublisherResponse, buffer_publisher_response, 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, + handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; @@ -308,6 +307,20 @@ fn health_response() -> Response { resp } +fn admin_key_management_not_supported() -> Response { + let body = edgezero_core::body::Body::from( + "Admin key management is not supported on Fermyon Spin.\n\ + Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", + ); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::NOT_IMPLEMENTED; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -456,28 +469,8 @@ fn build_router(state: &Arc) -> RouterService { } }; - // /_ts/admin/keys/rotate - let s = Arc::clone(&state); - let rotate_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = build_runtime_services(&ctx); - let req = ctx.into_request(); - Ok(handle_rotate_key(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } - }; - - // /_ts/admin/keys/deactivate - let s = Arc::clone(&state); - let deactivate_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = build_runtime_services(&ctx); - let req = ctx.into_request(); - Ok(handle_deactivate_key(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } + let admin_not_supported_handler = |_ctx: RequestContext| async { + Ok::(admin_key_management_not_supported()) }; // /auction @@ -643,8 +636,8 @@ fn build_router(state: &Arc) -> RouterService { // handler regex `^/_ts/admin` does not match them, and letting them // fall through to the publisher fallback would forward admin // credentials and key-management payloads to the origin. - .post("/_ts/admin/keys/rotate", rotate_handler) - .post("/_ts/admin/keys/deactivate", deactivate_handler) + .post("/_ts/admin/keys/rotate", admin_not_supported_handler) + .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) .post("/auction", auction_handler) .get("/first-party/proxy", fp_proxy_handler) .get("/first-party/click", fp_click_handler) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 943717e84..9b96dbd70 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -87,6 +87,32 @@ fn edgezero_manifest_loads_and_resolves_spin_stores() { // AuthMiddleware are wired so they cannot be removed silently. // --------------------------------------------------------------------------- +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_routes_return_501() { + for (path, body) in [ + ("/_ts/admin/keys/rotate", "{}"), + ( + "/_ts/admin/keys/deactivate", + r#"{"kid":"test-key","delete":false}"#, + ), + ] { + let req = request_builder() + .method("POST") + .uri(path) + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .header("content-type", "application/json") + .body(edgezero_core::body::Body::from(body)) + .expect("should build request"); + let resp = route(test_router(), req).await; + + assert_eq!( + resp.status().as_u16(), + 501, + "{path} should report that Spin key management is unsupported" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn health_route_returns_ok() { // Parity with the Fastly/Axum adapters: GET /health is a cheap liveness probe diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1e41044e8..badddf667 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10,11 +10,12 @@ //! Unsupported `Content-Encoding` values must bypass rewriting entirely. The //! streaming processor treats unknown encodings as identity, so publisher code //! must gate them out before the body enters the rewrite pipeline. -use std::io::Write; +use std::io::{Cursor, Read, Write}; use std::time::Duration; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; +use futures::{Stream, StreamExt}; use http::{header, HeaderValue, Method, Request, Response, StatusCode, Uri}; use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; @@ -32,8 +33,54 @@ use crate::streaming_replacer::create_url_replacer; const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"]; const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); -fn body_as_reader(body: EdgeBody) -> std::io::Cursor { - std::io::Cursor::new(body.into_bytes().unwrap_or_default()) +struct StreamingBodyReader { + stream: S, + current: Cursor, +} + +impl StreamingBodyReader { + fn new(stream: S) -> Self { + Self { + stream, + current: Cursor::new(bytes::Bytes::new()), + } + } +} + +impl Read for StreamingBodyReader +where + S: Stream> + Unpin, + E: std::fmt::Display, +{ + fn read(&mut self, output: &mut [u8]) -> std::io::Result { + if output.is_empty() { + return Ok(0); + } + + loop { + let bytes_read = self.current.read(output)?; + if bytes_read > 0 { + return Ok(bytes_read); + } + + match futures::executor::block_on(self.stream.next()) { + Some(Ok(chunk)) => self.current = Cursor::new(chunk), + Some(Err(error)) => return Err(std::io::Error::other(error.to_string())), + None => return Ok(0), + } + } + } +} + +fn body_as_reader(body: EdgeBody) -> Box { + if body.is_stream() { + let stream = body + .into_stream() + .expect("should contain a stream after checking the body variant"); + Box::new(StreamingBodyReader::new(stream)) + } else { + Box::new(Cursor::new(body.into_bytes().unwrap_or_default())) + } } fn not_found_response() -> Response { @@ -565,10 +612,42 @@ impl Write for BoundedWriter { /// Returns a [`TrustedServerError`] if the proxy request fails or the /// origin backend is unreachable. pub async fn handle_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + services: &RuntimeServices, + req: Request, +) -> Result> { + handle_publisher_request_with_mode(settings, integration_registry, services, req, false).await +} + +/// Proxies a publisher request while asking the platform adapter to preserve +/// the origin response body as a stream when full-document HTML +/// post-processing is not required. +/// +/// Fastly uses this path so processable responses can be rewritten directly +/// into the client stream without first materializing the origin body in the +/// Wasm heap. Adapters without streaming response support should call +/// [`handle_publisher_request`] instead. +/// +/// # Errors +/// +/// Returns a [`TrustedServerError`] if the proxy request fails or the origin +/// backend is unreachable. +pub async fn handle_streaming_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + services: &RuntimeServices, + req: Request, +) -> Result> { + handle_publisher_request_with_mode(settings, integration_registry, services, req, true).await +} + +async fn handle_publisher_request_with_mode( settings: &Settings, integration_registry: &IntegrationRegistry, services: &RuntimeServices, mut req: Request, + stream_origin_response: bool, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -687,9 +766,15 @@ pub async fn handle_publisher_request( })?, ); + let has_post_processors = integration_registry.has_html_post_processors(); + let mut platform_request = PlatformHttpRequest::new(req, backend_name); + if stream_origin_response { + platform_request = platform_request.with_stream_response(); + } + let mut response = services .http_client() - .send(PlatformHttpRequest::new(req, backend_name)) + .send(platform_request) .await .change_context(TrustedServerError::Proxy { message: "Failed to proxy request to origin".to_string(), @@ -728,8 +813,6 @@ pub async fn handle_publisher_request( .map(|h| h.to_str().unwrap_or_default()) .unwrap_or_default() .to_lowercase(); - let has_post_processors = integration_registry.has_html_post_processors(); - let route = classify_response_route( status, &content_type, @@ -799,6 +882,22 @@ pub async fn handle_publisher_request( ); let body = std::mem::replace(response.body_mut(), EdgeBody::empty()); + let body = if body.is_stream() { + let bytes = body + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes) + .await + .map_err(|error| { + Report::new(TrustedServerError::Proxy { + message: + "Failed to buffer publisher response for full-document processing" + .to_string(), + }) + .attach(format!("stream collection failed: {error:?}")) + })?; + EdgeBody::from_bytes(bytes) + } else { + body + }; let params = ProcessResponseParams { content_encoding: &content_encoding, origin_host: &origin_host, @@ -901,6 +1000,10 @@ mod tests { use crate::platform::test_support::{ build_services_with_http_client, noop_services, StubHttpClient, }; + use crate::platform::{ + PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, + PlatformResponse, PlatformSelectResult, + }; use crate::test_support::tests::create_test_settings; use edgezero_core::body::Body as EdgeBody; use http::{header, Method, Request as HttpRequest, StatusCode}; @@ -914,6 +1017,44 @@ mod tests { .expect("should build test request") } + struct StreamingPublisherHttpClient; + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for StreamingPublisherHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + assert!( + request.stream_response, + "streaming publisher requests should preserve the origin stream" + ); + let response = edgezero_core::http::response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html") + .body(EdgeBody::stream(futures::stream::iter([ + bytes::Bytes::from_static(b"link"), + ]))) + .map_err(|_| Report::new(PlatformError::HttpClient))?; + Ok(PlatformResponse::new(response)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + } + #[test] fn response_carries_body_preserves_bodiless_metadata() { // A processable GET 200 buffers a body and recomputes Content-Length. @@ -1834,6 +1975,82 @@ mod tests { ); } + #[test] + fn stream_publisher_body_processes_chunked_input() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let body = EdgeBody::stream(futures::stream::iter([ + bytes::Bytes::from_static(b"https://origin."), + bytes::Bytes::from_static(b"example.com/page"), + ])); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/plain".to_string(), + }; + + let mut output = Vec::new(); + stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) + .expect("should process a chunked publisher body"); + + assert_eq!( + std::str::from_utf8(&output).expect("output should be UTF-8"), + "https://proxy.example.com/page" + ); + } + + #[test] + fn streaming_publisher_request_buffers_only_full_document_processing() { + futures::executor::block_on(async { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should update nextjs config"); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + assert!( + registry.has_html_post_processors(), + "nextjs should require full-document HTML processing" + ); + let services = build_services_with_http_client(Arc::new(StreamingPublisherHttpClient) + as Arc); + let request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let publisher_response = + handle_streaming_publisher_request(&settings, ®istry, &services, request) + .await + .expect("should process streamed HTML requiring a full document"); + let response = match publisher_response { + PublisherResponse::Buffered(response) => response, + _ => panic!("HTML with post-processors should return a buffered response"), + }; + let output = response.body().as_bytes().unwrap_or_default(); + + assert!( + std::str::from_utf8(output) + .expect("output should be UTF-8") + .contains("publisher.example"), + "buffered post-processing should rewrite the streamed origin URL" + ); + }); + } + /// Empty origin body on the streaming route must produce no output /// without erroring. Exercises the `Ok(0)` branch of `process_chunks` /// plus the processor's `is_last=true, chunk=[]` terminal call. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 5075569b0..4b2a5c04c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -5040,33 +5040,33 @@ origin_host_header_overide = "www.example.com""#, } /// Verifies that [`Settings::ADMIN_ENDPOINTS`] stays in sync with the - /// admin route table in `crates/trusted-server-adapter-fastly/src/main.rs`. + /// admin route table in `crates/trusted-server-adapter-fastly/src/app.rs`. /// /// If this test fails, a route was added or removed in the Fastly /// router without updating `ADMIN_ENDPOINTS` (or vice versa). #[test] fn admin_endpoints_match_fastly_router() { - let router_source = include_str!("../../trusted-server-adapter-fastly/src/main.rs"); + let router_source = include_str!("../../trusted-server-adapter-fastly/src/app.rs"); for endpoint in Settings::ADMIN_ENDPOINTS { assert!( router_source.contains(endpoint), "ADMIN_ENDPOINTS lists \"{endpoint}\" but it was not found in \ - crates/trusted-server-adapter-fastly/src/main.rs — remove it from ADMIN_ENDPOINTS or \ + crates/trusted-server-adapter-fastly/src/app.rs — remove it from ADMIN_ENDPOINTS or \ add the route back to the router" ); } // Also verify we haven't missed any admin routes in the router. - // Best-effort: only detects string-literal routes in standard match-arm - // format. If you define admin routes differently (e.g. via constants or - // non-standard formatting), add them to ADMIN_ENDPOINTS manually. + // Best-effort: only detects string-literal routes in the NamedRoute + // table. If you define admin routes differently (e.g. via constants), + // add them to ADMIN_ENDPOINTS manually. let admin_routes_in_router: Vec<&str> = router_source .lines() .filter_map(|line| { let trimmed = line.trim(); - // Match arms look like: (Method::POST, "/_ts/admin/...") => ... - if trimmed.starts_with('(') && trimmed.contains("\"/_ts/admin/") { + // Route entries look like: path: "/_ts/admin/...", + if trimmed.starts_with("path: ") && trimmed.contains("\"/_ts/admin/") { let start = trimmed.find("\"/_ts/admin/")?; let rest = &trimmed[start + 1..]; let end = rest.find('"')?; diff --git a/crates/trusted-server-integration-tests/README.md b/crates/trusted-server-integration-tests/README.md index 3188746e3..e7263755d 100644 --- a/crates/trusted-server-integration-tests/README.md +++ b/crates/trusted-server-integration-tests/README.md @@ -49,9 +49,9 @@ This script: # Browser — single framework after building WASM/images and generating configs cd crates/trusted-server-integration-tests/browser -VICEROY_CONFIG_PATH=../../../target/integration-test-artifacts/configs/viceroy-legacy.toml \ +VICEROY_CONFIG_PATH=../../../target/integration-test-artifacts/configs/viceroy.toml \ TEST_FRAMEWORK=nextjs npx playwright test -VICEROY_CONFIG_PATH=../../../target/integration-test-artifacts/configs/viceroy-legacy.toml \ +VICEROY_CONFIG_PATH=../../../target/integration-test-artifacts/configs/viceroy.toml \ TEST_FRAMEWORK=wordpress npx playwright test ``` @@ -93,7 +93,7 @@ config is kept as readable TOML in `fixtures/configs/trusted-server.integration.toml` and converted into an EdgeZero `BlobEnvelope` at test setup time. -Generate both legacy and EdgeZero Viceroy configs manually with: +Generate the post-cutover Viceroy config manually with: ```bash ARTIFACTS_DIR=target/integration-test-artifacts \ @@ -101,15 +101,14 @@ INTEGRATION_ORIGIN_PORT=8888 \ ./scripts/generate-integration-viceroy-configs.sh ``` -Generated outputs: +Generated output: | File | Purpose | |---|---| -| `target/integration-test-artifacts/configs/viceroy-legacy.toml` | Standard legacy-entry integration and browser tests (`edgezero_enabled = "false"`) | -| `target/integration-test-artifacts/configs/viceroy-edgezero.toml` | EdgeZero EC lifecycle job (`edgezero_enabled = "true"`) | +| `target/integration-test-artifacts/configs/viceroy.toml` | Fastly integration, EC lifecycle, and browser tests | -Set `VICEROY_CONFIG_PATH` to one of those generated files when invoking -`cargo test` or Playwright directly. +Set `VICEROY_CONFIG_PATH` to the generated file when invoking `cargo test` or +Playwright directly. ## Test scenarios diff --git a/crates/trusted-server-integration-tests/browser/global-setup.ts b/crates/trusted-server-integration-tests/browser/global-setup.ts index e8c1245fc..f54d92dbe 100644 --- a/crates/trusted-server-integration-tests/browser/global-setup.ts +++ b/crates/trusted-server-integration-tests/browser/global-setup.ts @@ -20,7 +20,7 @@ const VICEROY_CONFIG = process.env.VICEROY_CONFIG_PATH || resolve( __dirname, - "../../../target/integration-test-artifacts/configs/viceroy-legacy.toml", + "../../../target/integration-test-artifacts/configs/viceroy.toml", ); /** Persist current state so global-teardown can always clean up. */ diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 99c39dc75..002c86f3e 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -67,8 +67,8 @@ data = "test-api-key" [local_server.config_stores] - # Generated integration configs inject the app_config blob and - # trusted_server_config rollout flag at this marker. + # Generated integration configs inject the app_config blob and the + # empty trusted_server_config store required by the Fastly entry point. # GENERATED_TRUSTED_SERVER_CONFIG_STORES [local_server.config_stores.jwks_store] diff --git a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index 19e23899e..2ec0e4568 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs @@ -16,7 +16,6 @@ struct Args { template: PathBuf, app_config: PathBuf, output: PathBuf, - edgezero_enabled: bool, origin_url: Option, } @@ -39,8 +38,7 @@ fn run(args: &Args) -> Result<(), DynError> { })?; let envelope_json = build_app_config_envelope(&app_config, args.origin_url.as_deref())?; - let generated_config = - inject_generated_config_stores(&template, &envelope_json, args.edgezero_enabled)?; + let generated_config = inject_generated_config_stores(&template, &envelope_json)?; if let Some(parent) = args.output.parent() { fs::create_dir_all(parent).map_err(|error| { @@ -64,7 +62,6 @@ fn parse_args(args: impl IntoIterator) -> Result let mut template = None; let mut app_config = None; let mut output = None; - let mut edgezero_enabled = None; let mut origin_url = None; let mut iter = args.into_iter(); @@ -73,14 +70,6 @@ fn parse_args(args: impl IntoIterator) -> Result "--template" => template = Some(next_path_arg(&mut iter, "--template")?), "--app-config" => app_config = Some(next_path_arg(&mut iter, "--app-config")?), "--output" => output = Some(next_path_arg(&mut iter, "--output")?), - "--edgezero-enabled" => { - let value = next_string_arg(&mut iter, "--edgezero-enabled")?; - edgezero_enabled = Some(parse_bool(&value).ok_or_else(|| { - error_box(format!( - "--edgezero-enabled must be `true` or `false`, got `{value}`" - )) - })?); - } "--origin-url" => origin_url = Some(next_string_arg(&mut iter, "--origin-url")?), "--help" | "-h" => return Err(error_box(usage())), other => { @@ -98,8 +87,6 @@ fn parse_args(args: impl IntoIterator) -> Result app_config: app_config .ok_or_else(|| error_box(format!("missing --app-config\n\n{}", usage())))?, output: output.ok_or_else(|| error_box(format!("missing --output\n\n{}", usage())))?, - edgezero_enabled: edgezero_enabled - .ok_or_else(|| error_box(format!("missing --edgezero-enabled\n\n{}", usage())))?, origin_url, }) } @@ -119,16 +106,8 @@ fn next_string_arg( .ok_or_else(|| error_box(format!("{flag} requires a value"))) } -fn parse_bool(value: &str) -> Option { - match value { - "true" => Some(true), - "false" => Some(false), - _ => None, - } -} - fn usage() -> String { - "usage: generate-viceroy-config --template --app-config --output --edgezero-enabled [--origin-url ]".to_string() + "usage: generate-viceroy-config --template --app-config --output [--origin-url ]".to_string() } fn build_app_config_envelope( @@ -153,11 +132,7 @@ fn build_app_config_envelope( .map_err(|error| error_box(format!("failed to serialize app-config envelope: {error}"))) } -fn inject_generated_config_stores( - template: &str, - envelope_json: &str, - edgezero_enabled: bool, -) -> Result { +fn inject_generated_config_stores(template: &str, envelope_json: &str) -> Result { let marker_count = template.matches(GENERATED_STORES_MARKER).count(); if marker_count != 1 { return Err(error_box(format!( @@ -165,13 +140,11 @@ fn inject_generated_config_stores( ))); } - let generated_stores = generated_config_store_blocks(envelope_json, edgezero_enabled); + let generated_stores = generated_config_store_blocks(envelope_json); Ok(template.replace(GENERATED_STORES_MARKER, &generated_stores)) } -fn generated_config_store_blocks(envelope_json: &str, edgezero_enabled: bool) -> String { - let edgezero_enabled_value = if edgezero_enabled { "true" } else { "false" }; - let edgezero_rollout_pct = if edgezero_enabled { "100" } else { "0" }; +fn generated_config_store_blocks(envelope_json: &str) -> String { format!( r#" # Generated by generate-viceroy-config. Do not edit generated output. [local_server.config_stores.app_config] @@ -179,12 +152,11 @@ fn generated_config_store_blocks(envelope_json: &str, edgezero_enabled: bool) -> [local_server.config_stores.app_config.contents] app_config = '''{envelope_json}''' - # Preserves the Fastly rollout flag location used by production. + # The post-cutover entry point still opens this store, but no longer + # reads rollout flags from it. [local_server.config_stores.trusted_server_config] format = "inline-toml" - [local_server.config_stores.trusted_server_config.contents] - edgezero_enabled = "{edgezero_enabled_value}" - edgezero_rollout_pct = "{edgezero_rollout_pct}""# + [local_server.config_stores.trusted_server_config.contents]"# ) } @@ -200,6 +172,23 @@ mod tests { const TEMPLATE: &str = include_str!("../../fixtures/configs/viceroy-template.toml"); const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.integration.toml"); + #[test] + fn parse_args_does_not_require_removed_rollout_switch() { + let result = parse_args([ + "--template".to_string(), + "template.toml".to_string(), + "--app-config".to_string(), + "trusted-server.toml".to_string(), + "--output".to_string(), + "generated.toml".to_string(), + ]); + + assert!( + result.is_ok(), + "post-cutover config generation should not require --edgezero-enabled" + ); + } + #[test] fn parse_args_accepts_required_flags_and_origin_override() { let args = parse_args([ @@ -209,8 +198,6 @@ mod tests { "trusted-server.toml".to_string(), "--output".to_string(), "generated.toml".to_string(), - "--edgezero-enabled".to_string(), - "true".to_string(), "--origin-url".to_string(), "http://127.0.0.1:9999".to_string(), ]) @@ -222,7 +209,6 @@ mod tests { template: PathBuf::from("template.toml"), app_config: PathBuf::from("trusted-server.toml"), output: PathBuf::from("generated.toml"), - edgezero_enabled: true, origin_url: Some("http://127.0.0.1:9999".to_string()) }, "should parse expected args" @@ -230,9 +216,9 @@ mod tests { } #[test] - fn generated_config_contains_blob_and_rollout_flag() { + fn generated_config_contains_blob_without_removed_rollout_flags() { let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope"); - let generated = inject_generated_config_stores(TEMPLATE, &envelope, true) + let generated = inject_generated_config_stores(TEMPLATE, &envelope) .expect("should inject generated stores"); assert!( @@ -240,12 +226,12 @@ mod tests { "should include app config store" ); assert!( - generated.contains("edgezero_enabled = \"true\""), - "should include enabled rollout flag" + !generated.contains("edgezero_enabled"), + "should omit the removed edgezero_enabled flag" ); assert!( - generated.contains("edgezero_rollout_pct = \"100\""), - "should include full rollout for enabled EdgeZero config" + !generated.contains("edgezero_rollout_pct"), + "should omit the removed edgezero_rollout_pct flag" ); assert!( generated.contains("[local_server.config_stores.jwks_store]"), @@ -253,35 +239,18 @@ mod tests { ); } - #[test] - fn generated_config_can_disable_edgezero() { - let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope"); - let generated = inject_generated_config_stores(TEMPLATE, &envelope, false) - .expect("should inject generated stores"); - - assert!( - generated.contains("edgezero_enabled = \"false\""), - "should include disabled rollout flag" - ); - assert!( - generated.contains("edgezero_rollout_pct = \"0\""), - "should include zero rollout for legacy config" - ); - } - #[test] fn generated_config_is_valid_toml() { let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope"); - let generated = inject_generated_config_stores(TEMPLATE, &envelope, true) + let generated = inject_generated_config_stores(TEMPLATE, &envelope) .expect("should inject generated stores"); let parsed: toml::Value = toml::from_str(&generated).expect("should parse as TOML"); - assert_eq!( + assert!( parsed["local_server"]["config_stores"]["trusted_server_config"]["contents"] - ["edgezero_enabled"] - .as_str(), - Some("true"), - "should expose rollout flag as string config-store value" + .as_table() + .is_some_and(toml::Table::is_empty), + "trusted_server_config should remain present with no rollout flags" ); } @@ -306,7 +275,7 @@ mod tests { #[test] fn missing_marker_fails() { - let result = inject_generated_config_stores("[local_server]", "{}", false); + let result = inject_generated_config_stores("[local_server]", "{}"); assert!(result.is_err(), "should reject templates without marker"); } diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 89799049e..4dc971d0e 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -41,3 +41,32 @@ pub fn cloudflare_config_json(origin_port: u16) -> TestResult { )) }) } + +#[cfg(test)] +mod tests { + const FASTLY_CONFIG: &str = include_str!("../../../../fastly.toml"); + + #[test] + fn local_fastly_config_defines_runtime_kv_stores() { + let parsed: toml::Value = + toml::from_str(FASTLY_CONFIG).expect("should parse root fastly.toml"); + let stores = &parsed["local_server"]["kv_stores"]; + + assert!( + stores["counter_store"].is_array(), + "fastly.toml should define counter_store for batch-sync rate limiting" + ); + let ec_entries = stores["ec_identity_store"] + .as_array() + .expect("fastly.toml should define ec_identity_store"); + assert!( + ec_entries.iter().any(|entry| { + entry["key"].as_str() + == Some( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01", + ) + }), + "fastly.toml should preserve the pre-seeded local EC test row" + ); + } +} diff --git a/crates/trusted-server-integration-tests/tests/common/ec.rs b/crates/trusted-server-integration-tests/tests/common/ec.rs index 7cc91781a..cde6ad1c4 100644 --- a/crates/trusted-server-integration-tests/tests/common/ec.rs +++ b/crates/trusted-server-integration-tests/tests/common/ec.rs @@ -271,6 +271,25 @@ fn mappings_to_json(mappings: &[BatchMapping]) -> Vec { // Assertion helpers // --------------------------------------------------------------------------- +/// Asserts the running Viceroy instance is serving the post-cutover `EdgeZero` +/// entry point. +/// +/// The `EdgeZero` router returns a router-level `405` for methods outside its +/// registered set (e.g. `TRACE`). This canary catches stale binaries or broken +/// fixtures that fail before reaching the router. +pub fn assert_edgezero_entry_point(base_url: &str) -> TestResult<()> { + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("should build EdgeZero canary client"); + let response = client + .request(reqwest::Method::TRACE, format!("{base_url}/")) + .send() + .change_context(TestError::HttpRequest) + .attach("TRACE / (EdgeZero entry-point canary)")?; + assert_status(&response, 405).attach("EdgeZero canary: TRACE should return a router-level 405") +} + pub fn assert_status(resp: &Response, expected: u16) -> TestResult<()> { let actual = resp.status().as_u16(); if actual != expected { diff --git a/crates/trusted-server-integration-tests/tests/environments/fastly.rs b/crates/trusted-server-integration-tests/tests/environments/fastly.rs index ed696cf84..124a380f6 100644 --- a/crates/trusted-server-integration-tests/tests/environments/fastly.rs +++ b/crates/trusted-server-integration-tests/tests/environments/fastly.rs @@ -10,8 +10,7 @@ use std::process::{Child, Command, Stdio}; /// /// Spawns a `viceroy` child process with the WASM binary and the /// generated Viceroy config (runtime resources plus Trusted Server app-config -/// blob). Legacy-path settings are still baked into the WASM binary at build -/// time; the EdgeZero-path settings come from the generated `app_config` blob. +/// blob). pub struct FastlyViceroy; impl RuntimeEnvironment for FastlyViceroy { @@ -75,9 +74,9 @@ impl FastlyViceroy { /// secret stores) plus generated test application config stores. /// /// Honors the `VICEROY_CONFIG_PATH` environment variable so CI jobs can - /// point the same WASM binary at generated legacy or `EdgeZero` configs. This - /// mirrors the browser harness's `global-setup.ts`, which reads the same - /// variable. Falls back to the local generated legacy config path when unset. + /// select a generated config. This mirrors the browser harness's + /// `global-setup.ts`, which reads the same variable. Falls back to the local + /// generated config path when unset. fn viceroy_config_path(&self) -> std::path::PathBuf { if let Ok(path) = std::env::var("VICEROY_CONFIG_PATH") && !path.is_empty() @@ -85,7 +84,7 @@ impl FastlyViceroy { return std::path::PathBuf::from(path); } std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../target/integration-test-artifacts/configs/viceroy-legacy.toml") + .join("../../target/integration-test-artifacts/configs/viceroy.toml") } } diff --git a/crates/trusted-server-integration-tests/tests/integration.rs b/crates/trusted-server-integration-tests/tests/integration.rs index 2e0be2ff9..76a267d1f 100644 --- a/crates/trusted-server-integration-tests/tests/integration.rs +++ b/crates/trusted-server-integration-tests/tests/integration.rs @@ -18,6 +18,18 @@ fn init_logger() { let _ = env_logger::try_init(); } +#[test] +fn default_fastly_viceroy_template_has_generated_config_store_marker() { + let template = include_str!("../fixtures/configs/viceroy-template.toml"); + const MARKER: &str = "# GENERATED_TRUSTED_SERVER_CONFIG_STORES"; + + assert_eq!( + template.matches(MARKER).count(), + 1, + "default Fastly Viceroy template should contain exactly one generated config-store marker" + ); +} + /// Test all combinations: frameworks x runtimes (matrix testing). /// /// Iterates every registered runtime and framework, running all standard @@ -201,6 +213,10 @@ fn test_ec_lifecycle_fastly() { process.base_url ); + // Verify the post-cutover router is active before route-level scenarios. + common::ec::assert_edgezero_entry_point(&process.base_url) + .expect("EdgeZero entry-point canary failed: TRACE did not return a router-level 405"); + for scenario in EcScenario::all() { log::info!(" Running EC scenario: {scenario:?}"); let result = scenario.run(&process.base_url); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4dda333c2..21c1caf2c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1227,35 +1227,16 @@ TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000 TRUSTED_SERVER__AUCTION__CREATIVE_STORE=creative_store ``` -## Runtime Feature Flags +## Fastly Runtime Config Store -Unlike the TOML/environment settings above (which are baked into the Wasm binary -at build time), the EdgeZero entry point is toggled at **runtime** through a -Fastly Config Store. This lets operators switch request handling without -rebuilding or redeploying the binary. +After the EdgeZero cutover, the Fastly adapter always dispatches through the +EdgeZero entry point. The former `edgezero_enabled` and `edgezero_rollout_pct` +canary keys are no longer read. -### EdgeZero Canary Routing - -**Purpose**: Routes incoming requests through the EdgeZero entry point instead of -the legacy entry point, with an optional percentage rollout. - -**Source**: Fastly Config Store, not `trusted-server.toml` or an environment -variable. - -| Key | Value | Behavior | -| ---------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `edgezero_enabled` | `true` or `1` (whitespace-trimmed, any ASCII case) | Master switch. Any other value, missing key, or read error routes all traffic to legacy. | -| `edgezero_rollout_pct` | Integer string `0` through `100` | Percentage of enabled traffic routed to EdgeZero by deterministic client-IP bucket. | - -**Fallback behavior** (fail-safe to the legacy path): - -- If the `trusted_server_config` store cannot be opened, requests use the legacy - path. -- If the `edgezero_enabled` key is missing or cannot be read, requests use the - legacy path. -- Any value other than `true`/`1` keeps the legacy path. -- If `edgezero_enabled=true` but `edgezero_rollout_pct` is missing, invalid, or - cannot be read, rollout defaults to `0` and requests use the legacy path. +The Fastly service must still provide a `trusted_server_config` config store +because the entry point opens it before dispatch and passes the handle to +EdgeZero-backed platform services. The store may be empty unless another feature +adds keys to it. **Local development** (`fastly.toml`): @@ -1264,29 +1245,18 @@ variable. [local_server.config_stores.trusted_server_config] format = "inline-toml" [local_server.config_stores.trusted_server_config.contents] - edgezero_enabled = "true" - edgezero_rollout_pct = "0" ``` **Production setup** (Fastly CLI): ```bash -# Create the store (once) and attach it to the service, then set the keys. +# Create the store once and attach it to the service. fastly config-store create --name trusted_server_config -fastly config-store-entry create \ - --store-id --key edgezero_enabled --value true -fastly config-store-entry create \ - --store-id --key edgezero_rollout_pct --value 0 ``` -**Rollout**: With `edgezero_enabled=true`, increase `edgezero_rollout_pct` -through staged values such as `1`, `10`, `50`, and `100`. Routing is sticky per -client IP at a given percentage. - -**Rollback**: Set `edgezero_rollout_pct` to `0` to return enabled traffic to the -legacy path immediately. Set `edgezero_enabled` to `false` (or delete the key) to -disable the EdgeZero entry point entirely. Neither rollback requires a rebuild or -redeploy. +Rollback to the legacy entry point is no longer controlled by runtime config +keys. Use the normal deployment rollback path to restore a pre-cleanup service +version if that is required. ## Validation diff --git a/docs/internal/EDGEZERO_MIGRATION.md b/docs/internal/EDGEZERO_MIGRATION.md index 276b3929c..9543f5b4b 100644 --- a/docs/internal/EDGEZERO_MIGRATION.md +++ b/docs/internal/EDGEZERO_MIGRATION.md @@ -1,168 +1,85 @@ # EdgeZero Migration Runbook -Operational reference for the Fastly Compute EdgeZero canary rollout +Operational reference for the Fastly Compute EdgeZero migration (issue [#500](https://github.com/IABTechLab/trusted-server/issues/500), epic [#480](https://github.com/IABTechLab/trusted-server/issues/480)). --- -## Config store keys +## Current Status -Config store name: **`trusted_server_config`** (Fastly service config store) - -| Key | Type | Effect | -| ---------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `edgezero_enabled` | `"true"` / `"false"` | Master on/off switch. Set `"false"` to disable EdgeZero entirely, regardless of rollout_pct. | -| `edgezero_rollout_pct` | `"0"` – `"100"` | Percentage of traffic (by client IP bucket) routed to EdgeZero. Only read when `edgezero_enabled = "true"`. Key absent = `"0"` (fail safe to legacy). | - -**Routing logic:** `fnv1a_bucket(client_ip) < edgezero_rollout_pct` → EdgeZero, else legacy. -Same client IP always gets the same bucket — routing is sticky per client IP (not per -user; a user whose IP changes, e.g. mobile roaming or ISP reassignment, may re-bucket and -switch paths, and could observe inconsistent identity if the two paths differ in EC handling). +The Fastly legacy cleanup has removed `legacy_main()` and the runtime canary +flag plumbing. The Fastly entry point now always dispatches through +`edgezero_main()`. -### Safe defaults / failure modes +The historical canary keys are no longer read: -| Condition | Effective behaviour | -| --------------------------------------------------- | ---------------------- | -| Config store unreachable | All legacy | -| `edgezero_enabled` unreadable | All legacy | -| `edgezero_rollout_pct` absent (but enabled=true) | All legacy (fail safe) | -| `edgezero_rollout_pct` invalid (non-integer, > 100) | All legacy | -| `edgezero_rollout_pct = "0"` | All legacy (rollback) | +- `edgezero_enabled` +- `edgezero_rollout_pct` -> **Note:** Every non-explicit state fails safe to legacy — an absent, invalid, or unreadable -> `edgezero_rollout_pct` all route 100% to the legacy path, so deleting the key can never trigger -> a cutover. To roll out, set an explicit percentage; to pause or roll back, set `"0"`. +Do not use those keys for rollout or rollback after the legacy cleanup. --- -## Canary progression - -> **Pre-condition:** All Phase 5 verification gates (PR18) passed. - -### Pre-flight activation - -Before advancing any stage, activate the canary switch: - -1. Confirm `edgezero_rollout_pct = "0"` (or absent — both fail safe to legacy) in the - production config store. Setting it explicitly to `"0"` documents intent. -2. Set `edgezero_enabled = "true"` in the production config store. -3. Confirm the flag is live and all traffic is still on the legacy path. - `rollout_pct = "0"` deterministically short-circuits every request to the - legacy path (`should_route_to_edgezero` in the Fastly entry point), so all-legacy - is guaranteed by the config value rather than observed per request: confirm the - config-store values are applied and that error rate, p95 latency, and timeout - rate hold at baseline. There is no production per-branch route signal to tail - (see [Monitoring](#monitoring)); the `routing request through legacy path -(rollout_pct=0)` line is emitted at `debug!`, so it surfaces only in local - Viceroy runs, where the logger auto-raises to `debug` via - `FASTLY_HOSTNAME=localhost` (production stays at `Info`, which suppresses it). - -### Stage 1 — 1% - -1. Set `edgezero_rollout_pct = "1"` in the production config store. -2. Hold **30 minutes**. -3. Check pass/fail thresholds (see below). -4. If all green → advance to Stage 2. If any threshold breached → rollback. - -### Stage 2 — 10% - -1. Set `edgezero_rollout_pct = "10"`. -2. Hold **2 hours** (same time-of-day window as the 7-day baseline). -3. Check pass/fail thresholds. -4. If all green → advance to Stage 3. If any threshold breached → rollback. +## Runtime Config Store -### Stage 3 — 50% - -1. Set `edgezero_rollout_pct = "50"`. -2. Hold **24 hours**. -3. Check pass/fail thresholds. Pay particular attention to auction win-rate. -4. If all green → advance to Stage 4. If any threshold breached → rollback. - -### Stage 4 — 100% (full cutover) - -1. Set `edgezero_rollout_pct = "100"`. -2. Hold **48 hours** before decommissioning the legacy entry point. -3. Confirm zero regressions across all metrics. -4. Open legacy cleanup PR (removes `legacy_main()` and flag plumbing, see issue #495). - ---- - -## Pass/fail thresholds +Config store name: **`trusted_server_config`** (Fastly service config store) -**Baseline definition:** 7-day rolling average from production Fastly service -metrics, sampled from the same time-of-day window as the canary observation -period (to account for diurnal traffic patterns). +The store must exist because the Fastly entry point opens it before EdgeZero +dispatch and passes the handle to EdgeZero-backed platform services. The store +may be empty unless another feature adds keys to it. -| Metric | Threshold | Action if breached | -| ---------------- | ------------------------ | -------------------------------------- | -| Error rate (5xx) | > 0.1% above baseline | **Immediate rollback** | -| p95 latency | > 15% above baseline | Hold; rollback if no fix within 1 hour | -| Auction win-rate | > 1% delta from baseline | Hold; investigate | -| Timeout rate | > 2× baseline | **Immediate rollback** | +| Condition | Effective behaviour | +| --------------------------------- | -------------------------------------------- | +| `trusted_server_config` exists | Request dispatches through `edgezero_main()` | +| `trusted_server_config` is absent | Request returns `500 Internal Server Error` | -> **Note on p95 threshold:** The spec §Cutover paragraph mentions ±10% as the Stage 2 hold-point -> criterion; the threshold table at §Pass/fail thresholds says 15%. These two values are -> inconsistent in the spec. This runbook adopts the threshold table (15%) as the governing -> number because it applies uniformly across all stages. If ops adopts a stricter 10% target -> at Stage 2, update this table accordingly. +Local Viceroy templates must define an inline `trusted_server_config` store even +when no keys are present. --- -## Rollback procedure +## Rollback Procedure -Rollback is **immediate, no deploy required**. +Rollback is no longer controlled by runtime config store keys. To roll back the +legacy cleanup, use the normal Fastly deployment rollback path: -1. Set `edgezero_rollout_pct = "0"` in the production config store. - Traffic shifts back to legacy within a few seconds as the config store propagates - across edge PoPs; each Wasm instance picks up the change on its next request. -2. Optionally set `edgezero_enabled = "false"` as belt-and-suspenders. -3. Investigate root cause before re-advancing the canary. -4. Keep the legacy entry point (`legacy_main()`) available until at least one - full release cycle after reaching 100% with zero regressions. +1. Re-activate a previous service version that still contains the dual-path + entry point, or deploy a revert of the legacy cleanup change. +2. Verify health, error rate, timeout rate, p95 latency, and auction win-rate + against the same baseline window used for cutover. +3. If rolling back to a pre-cleanup build, use the historical PR19 canary + runbook before changing `edgezero_enabled` or `edgezero_rollout_pct`. --- ## Monitoring -> **There is no production signal that splits traffic by EdgeZero-vs-legacy -> branch yet.** The per-request route decision is emitted only at `log::debug!` -> (`should_route_to_edgezero` in the Fastly entry point), and the Fastly logger -> defaults to `Info` (`logging::init_logger`), so these lines do not reach the -> production log endpoint. The logger auto-raises to `debug` only under Viceroy, -> detected via the guest-visible `FASTLY_HOSTNAME=localhost` signal, so the route -> decision is visible only in local runs and never in production. No -> `x-edgezero-path` response-path marker exists (deferred -> follow-up), and no Fastly real-time-stats traffic split is configured for this -> decision. Until a production-safe per-branch signal is added, canary -> verification relies on aggregate service metrics moving as expected when -> `rollout_pct` is stepped — not on per-request branch attribution. - -Fastly real-time stats dashboard — aggregate service signals (not split by -branch). Watch each as `rollout_pct` is increased stage by stage; a regression -that appears and tracks the rollout steps implicates the EdgeZero branch: +After cleanup there is only one Fastly request path, so monitoring no longer +needs an EdgeZero-vs-legacy branch split. Watch aggregate service metrics: - **Error rate:** `5xx / total_requests` by edge PoP - **Latency p95:** service-wide - **Auction win-rate:** downstream SSP reporting, compare same-day prior week - **Timeout rate:** `504 / total_requests` -> For local pre-production validation under Viceroy, start the simulator normally -> (`fastly compute serve`). Viceroy exposes `FASTLY_HOSTNAME=localhost` to guest -> code, and the Fastly logger raises the route-decision level to `debug` in that -> local environment while production stays at `Info`. The route-decision log lines -> are then: -> -> - `routing request through EdgeZero path (bucket=N, rollout_pct=M)` — partial-stage canary traffic. -> - `routing request through legacy path (bucket=N, rollout_pct=M)` — partial-stage legacy traffic. -> - At the degenerate values the bucket is not computed: -> `routing request through legacy path (rollout_pct=0)` (full rollback) and -> `routing request through EdgeZero path (rollout_pct=100)` (full cutover). +For local validation under Viceroy, use the integration-test fixtures or +`fastly compute serve` with a `trusted_server_config` store present. + +--- + +## Historical Rollout + +The staged canary progression (`1% -> 10% -> 50% -> 100%`) and instant rollback +via `edgezero_rollout_pct = "0"` applied only before legacy cleanup. Historical +details are preserved in +`docs/superpowers/plans/2026-05-21-pr19-cutover-canary-rollout.md`. --- ## Reference -- Spec: `docs/superpowers/specs/2026-03-19-edgezero-migration-design.md` §Cutover plan -- Plan: `docs/superpowers/plans/2026-05-21-pr19-cutover-canary-rollout.md` +- Spec: `docs/superpowers/specs/2026-03-19-edgezero-migration-design.md` +- Cutover plan: `docs/superpowers/plans/2026-05-21-pr19-cutover-canary-rollout.md` +- Legacy cleanup plan: `docs/superpowers/plans/2026-05-27-pr20-legacy-cleanup.md` - Legacy cleanup: issue [#495](https://github.com/IABTechLab/trusted-server/issues/495) diff --git a/docs/superpowers/plans/2026-05-27-pr20-legacy-cleanup.md b/docs/superpowers/plans/2026-05-27-pr20-legacy-cleanup.md new file mode 100644 index 000000000..38584a3d7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-pr20-legacy-cleanup.md @@ -0,0 +1,931 @@ +# PR20 — Legacy Entry Point Cleanup 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:** Delete `legacy_main()`, the canary routing machinery (`edgezero_enabled`/`edgezero_rollout_pct` flag plumbing), and all code that only existed to support the legacy path — completing issue #501. + +**Architecture:** All traffic now flows through `edgezero_main()`. The entry point `main()` becomes a thin trampoline: fast-path health check → logging init → call `edgezero_main()`. The canary config-store flag read is gone; the config store is opened once inside `edgezero_main()` (required for `dispatch_with_config_handle`). Dead test coverage for `route_request()` and `HandlerOutcome` is removed; equivalent EdgeZero-path coverage already lives in `app.rs`. + +**Tech Stack:** Rust / Fastly Compute (`wasm32-wasip1`), `edgezero-adapter-fastly`, standard `http` crate types. + +--- + +## File Map + +| File | Action | What changes | +| --------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-adapter-fastly/src/main.rs` | Modify (major) | Delete `legacy_main`, `route_request`, `HandlerOutcome`, all canary flag functions/constants/tests, `build_ja4_debug_response`, `finalize_response`, `http_error_response`, `resolve_publisher_response`, FALLBACK\_\* constants; simplify `main()` and `edgezero_main()`; remove `mod error;`; clean up imports | +| `crates/trusted-server-adapter-fastly/src/route_tests.rs` | **Delete** | Entire file — all tests reference deleted `route_request`/`HandlerOutcome`; EdgeZero dispatch coverage lives in `app.rs` | +| `crates/trusted-server-adapter-fastly/src/error.rs` | **Delete** | Entire file — `to_error_response()` is the only export; it is only called from `legacy_main()` | +| `crates/trusted-server-adapter-fastly/src/compat.rs` | Modify | Delete `from_fastly_request()`, its private helper `build_http_request()`, and `to_fastly_response_skeleton()` — all legacy-only. Keep `from_fastly_response()`, `to_fastly_response()`, `sanitize_fastly_forwarded_headers()` and their tests. Update module doc comment. | +| `crates/trusted-server-adapter-fastly/src/platform.rs` | Modify | Delete `build_runtime_services()`, its `noop_kv_store()` test helper, and its two unit tests — all legacy-only. Update module doc comment. | +| `crates/trusted-server-adapter-fastly/src/middleware.rs` | Modify (doc only) | Remove stale intra-doc links to deleted symbols `finalize_response` and `route_request` | +| `crates/trusted-server-adapter-fastly/src/app.rs` | Modify (doc only) | Remove stale reference to `crate::http_error_response` in `http_error()` doc comment | +| `fastly.toml` | Modify | Remove `edgezero_enabled` and `edgezero_rollout_pct` keys from `[local_server.config_stores.trusted_server_config.contents]` | + +**Not touched:** `backend.rs`, `logging.rs`, `management_api.rs`. All other adapter crates (`axum`, `cloudflare`, `spin`) are untouched. + +--- + +## What to delete vs keep in `main.rs` + +### Delete — complete functions/types + +| Symbol | Lines (approx) | Reason | +| ------------------------------------------ | -------------- | ------------------------------------------------------------- | +| `const EDGEZERO_ENABLED_KEY` | 55 | Flag removed | +| `const EDGEZERO_ROLLOUT_PCT_KEY` | 56 | Flag removed | +| `enum HandlerOutcome` + `impl` | 62-78 | Legacy-path-only type | +| `fn parse_edgezero_flag()` | 84-87 | Flag removed | +| `fn parse_rollout_pct()` | 94-100 | Flag removed | +| `fn fnv1a_bucket()` | 108-117 | Flag removed | +| `fn canary_routes_to_edgezero()` | 124-131 | Flag removed | +| `fn is_edgezero_enabled()` | 154-159 | Flag removed | +| `fn read_rollout_pct()` | 169-195 | Flag removed | +| `const FALLBACK_UNAVAILABLE/NOT_SENT/NONE` | 433-435 | JA4 debug only | +| `fn build_ja4_debug_response()` | 438-479 | `// TODO: remove after JA4 evaluation` — was legacy-path only | +| `fn legacy_main()` | 346-431 | THE main deliverable of this PR | +| `async fn route_request()` | 481-591 | Legacy-path-only dispatcher | +| `fn resolve_publisher_response()` | 593-612 | Called only by `route_request()` | +| `fn finalize_response()` | 650-652 | Thin wrapper used only by `legacy_main()` | +| `fn http_error_response()` | 654-666 | Legacy-path-only; EdgeZero path uses `app::http_error()` | + +### Delete — `mod` declaration and import + +| Symbol | Where | Reason | +| -------------------------------------- | ---------------- | --------------------------- | +| `mod error;` | main.rs ~line 41 | `error.rs` is being deleted | +| `use crate::error::to_error_response;` | main.rs ~line 50 | `error.rs` is being deleted | + +### Delete — tests in `mod tests` + +| Test name | Reason | +| -------------------------------------------------------- | ----------------------------------- | +| `parses_true_flag_values` | `parse_edgezero_flag` deleted | +| `rejects_non_true_flag_values` | `parse_edgezero_flag` deleted | +| `parses_valid_rollout_percentages` | `parse_rollout_pct` deleted | +| `rejects_invalid_rollout_percentages` | `parse_rollout_pct` deleted | +| `bucket_is_in_range_0_to_99` | `fnv1a_bucket` deleted | +| `bucket_is_deterministic` | `fnv1a_bucket` deleted | +| `bucket_matches_known_fnv1a_vector` | `fnv1a_bucket` deleted | +| `bucket_distributes_across_range` | `fnv1a_bucket` deleted | +| `empty_key_bucket_is_valid` | `fnv1a_bucket` deleted | +| `rollout_zero_routes_all_to_legacy` | `canary_routes_to_edgezero` deleted | +| `rollout_hundred_routes_all_to_edgezero` | `canary_routes_to_edgezero` deleted | +| `rollout_fifty_routes_exactly_half_of_bucket_space` | `canary_routes_to_edgezero` deleted | +| `rollout_one_routes_exactly_one_bucket` | `canary_routes_to_edgezero` deleted | +| `ja4_debug_response_uses_plain_text_and_fallback_values` | `build_ja4_debug_response` deleted | + +### Keep — functions (unchanged or slightly updated) + +| Symbol | Notes | +| ------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `const TRUSTED_SERVER_CONFIG_STORE` | Still used by `open_trusted_server_config_store()` | +| `fn open_trusted_server_config_store()` | Kept; called inside simplified `edgezero_main()`. Update doc comment. | +| `fn health_response()` | Kept; fast-path health probe in `main()` | +| `fn edgezero_main()` | Kept; signature changes: no `config_store` param; opens store internally | +| `fn response_was_finalized_by_middleware()` | Kept; used by `edgezero_main()` | +| `fn apply_entry_point_finalize()` | Kept; used by `edgezero_main()` | +| `pub(crate) fn resolve_publisher_response_buffered()` | Kept; called by `app.rs::dispatch_fallback()` | +| Tests: `health_response_*` | Kept | +| Tests: `response_was_finalized_by_middleware_strips_sentinel` | Kept | +| Tests: `entry_point_finalize_skips_geo_lookup_for_401` | Kept | + +### Imports to remove from `main.rs` + +After all deletions, these become unused (let the compiler confirm): + +```rust +// Delete entirely: +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::AuctionOrchestrator; +use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::request_signing::{ + handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, + handle_verify_signature, +}; + +// Trim these lines (keep only what's shown): + +// Was: use crate::app::{build_state, runtime_services_for_consent_route, TrustedServerApp}; +// build_state: only called from legacy_main; runtime_services_for_consent_route: only called from +// route_request (deleted). TrustedServerApp is still needed by edgezero_main. +use crate::app::TrustedServerApp; + +// Was: use crate::platform::{build_runtime_services, FastlyPlatformGeo}; +use crate::platform::FastlyPlatformGeo; + +// Was: use edgezero_core::http::{header, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse}; +// Method and Request: only used by route_request (deleted). +use edgezero_core::http::{header, HeaderValue, Response as HttpResponse}; + +// Was: use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; +// IntoHttpResponse: only in route_tests (deleted). TrustedServerError: used by +// resolve_publisher_response_buffered. Keep TrustedServerError only. +use trusted_server_core::error::TrustedServerError; + +// Was: use trusted_server_core::proxy::{handle_first_party_click, ...}; +// DELETE ENTIRE LINE — no proxy handlers remain in main.rs + +// Was: use trusted_server_core::publisher::{handle_publisher_request, handle_tsjs_dynamic, +// stream_publisher_body, OwnedProcessResponseParams, PublisherResponse}; +// handle_publisher_request, handle_tsjs_dynamic: only in route_request (deleted). +use trusted_server_core::publisher::{stream_publisher_body, OwnedProcessResponseParams, PublisherResponse}; +``` + +Also in `mod tests`: remove `use fastly::mime;` (only for ja4 test, which is deleted). + +`std::net::IpAddr`, `std::sync::Arc`, `edgezero_core::body::Body as EdgeBody`, `fastly::http::Method as FastlyMethod`, `fastly::{Request as FastlyRequest, Response as FastlyResponse}`, `edgezero_adapter_fastly::FastlyConfigStore`, `edgezero_core::config_store::ConfigStoreHandle`, `error_stack::Report`, `edgezero_core::app::Hooks as _` all remain. + +### Simplified `main()` — target state + +```rust +/// Entry point for the Fastly Compute program. +/// +/// Uses an undecorated `main()` with `FastlyRequest::from_client()` instead of +/// `#[fastly::main]` so the EdgeZero streaming publisher path can call +/// [`fastly::Response::stream_to_client`] explicitly. +fn main() { + let req = FastlyRequest::from_client(); + + // Health probe bypasses logging, settings, and app construction as a cheap liveness signal. + if let Some(response) = health_response(&req) { + response.send_to_client(); + return; + } + + logging::init_logger(); + edgezero_main(req); +} +``` + +### Simplified `edgezero_main()` — target state + +```rust +/// Handles a request through the EdgeZero router path. +fn edgezero_main(mut req: FastlyRequest) { + let config_store = match open_trusted_server_config_store() { + Ok(cs) => cs, + Err(e) => { + log::error!("failed to open config store: {e}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return; + } + }; + + let app = TrustedServerApp::build_app(); + + // Strip client-spoofable forwarded headers before dispatch. + compat::sanitize_fastly_forwarded_headers(&mut req); + + // Capture client IP before the request is consumed by dispatch. + let client_ip = req.get_client_ip_addr(); + + // `dispatch_with_config_handle` skips logger initialisation and injects + // the config store directly (init_logger already called in main()). + let mut response = + match edgezero_adapter_fastly::dispatch_with_config_handle(&app, req, config_store) { + Ok(response) => compat::from_fastly_response(response), + Err(e) => { + log::error!("EdgeZero dispatch failed: {e}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return; + } + }; + + if !response_was_finalized_by_middleware(&mut response) { + match get_settings() { + Ok(settings) => { + apply_entry_point_finalize(&settings, client_ip, &mut response, |client_ip| { + FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + log::warn!("entry-point geo lookup failed: {e}"); + None + }) + }) + } + Err(e) => { + log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}"); + } + } + } + + compat::to_fastly_response(response).send_to_client(); +} +``` + +### Updated `open_trusted_server_config_store()` doc comment + +```rust +/// Opens the Fastly Config Store used by the EdgeZero dispatcher. +/// +/// # Errors +/// +/// Returns [`fastly::Error`] if the config store cannot be opened. +fn open_trusted_server_config_store() -> Result { +``` + +--- + +## What to delete vs keep in `compat.rs` + +### Delete + +| Symbol | Lines | Reason | +| --------------------------------------------- | ----- | --------------------------------------------------- | +| `fn build_http_request()` | 11-28 | Private helper only used by `from_fastly_request()` | +| `pub(crate) fn from_fastly_request()` | 35-38 | Only called from `legacy_main()` | +| `pub(crate) fn to_fastly_response_skeleton()` | 78-85 | Only called from `legacy_main()` streaming path | + +### Keep + +- `pub(crate) fn from_fastly_response()` — used by `edgezero_main()` +- `pub(crate) fn to_fastly_response()` — used by `edgezero_main()` +- `pub(crate) fn sanitize_fastly_forwarded_headers()` — used by `edgezero_main()` +- Both test functions in `mod tests` — test shared functions above + +### Update module doc comment + +Replace the current: + +```rust +//! Contains only the functions used by the legacy `main()` entry point. +//! Relocated from `trusted-server-core` as part of removing all `fastly` crate +//! imports from the core library. +``` + +With: + +```rust +//! Compatibility bridge between `fastly` SDK types and `http` crate types. +``` + +--- + +## What to delete in `platform.rs` + +- `pub fn build_runtime_services(...)` — only caller was `legacy_main()` +- `fn noop_kv_store()` test helper — only used by the two `build_runtime_services` tests +- `fn build_runtime_services_client_info_is_none_without_tls()` test +- `fn build_runtime_services_returns_cloneable_services()` test +- Update module doc: remove the sentence "This module also provides `build_runtime_services`, a free function that..." + +--- + +## What to update in `middleware.rs` + +Two stale intra-doc links after `finalize_response` and `route_request` are deleted: + +**Line ~4 (module doc):** +Remove: `from the legacy [\`crate::finalize_response\`] and [\`crate::route_request\`]:` + +**Line ~150 (fn-level doc on `apply_finalize_headers` or equivalent):** +Remove: `Mirrors [\`crate::finalize_response\`] exactly` + +Use `grep -n "finalize_response\|route_request" middleware.rs` to find exact lines before editing. + +--- + +## What to remove from `fastly.toml` + +The `[local_server.config_stores.trusted_server_config.contents]` block currently has two keys the app no longer reads. Remove them and their comments, leaving the store entry intact (the store itself is still needed for EdgeZero dispatch): + +```toml +# Before (remove these lines): + # "true" / "1" (case-insensitive) enable the EdgeZero path. Missing, + # unreadable, or any other value falls back to the legacy entry point. + # Keep "false" until EdgeZero reaches full functional parity with legacy. + edgezero_enabled = "false" + # Integer 0-100. Effective only when edgezero_enabled = "true". + # 0 -> all traffic to legacy (instant rollback — no deploy needed) + # 1-99 -> canary: clients whose fnv1a_bucket(client_ip) < this value go EdgeZero + # 100 -> all traffic to EdgeZero (full cutover) + # Key absent when edgezero_enabled = "true" is treated as 100 (full rollout). + # IMPORTANT: Set this to "0" in production BEFORE setting edgezero_enabled = "true". + edgezero_rollout_pct = "0" +``` + +The resulting `[local_server.config_stores.trusted_server_config.contents]` block should be empty `{}` — the store entry stays because `open_trusted_server_config_store()` still opens it for the EdgeZero dispatcher. + +--- + +## Tasks + +> **IMPORTANT about tasks 2-4:** Tasks 2 and 3 leave `main.rs` in a broken-compilation state. Do NOT run `cargo check` between Tasks 2 and 4 except at the explicitly marked checkpoints. Task 4 completes the main.rs rewrite and restores compilability. + +--- + +### Task 1: Delete `route_tests.rs` and its `mod` declaration + +**Files:** + +- Delete: `crates/trusted-server-adapter-fastly/src/route_tests.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +- [ ] **Step 1: Delete `route_tests.rs`** + +```bash +git rm crates/trusted-server-adapter-fastly/src/route_tests.rs +``` + +- [ ] **Step 2: Remove the `mod route_tests` declaration from `main.rs` (~line 46-47)** + +Delete: + +```rust +#[cfg(test)] +mod route_tests; +``` + +- [ ] **Step 3: Verify compiles** + +```bash +cargo check -p trusted-server-adapter-fastly +``` + +Expected: PASS. + +- [ ] **Step 4: Run Fastly tests to confirm baseline** + +```bash +cargo test-fastly +``` + +Expected: PASS (tests in `main.rs` and `app.rs` still run). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Remove legacy route_request test file + +route_tests.rs tested route_request() and HandlerOutcome, which are +legacy-path only. Equivalent EdgeZero dispatch coverage exists in +app.rs. Part of #501 legacy entry point cleanup." +``` + +--- + +### Task 2: Delete `legacy_main()` and all legacy-path-only code from `main.rs` + +> Do all deletions in one pass before running `cargo check`. They have cross-references; partial deletion will not compile. The ONLY intentional `cargo check` in this task is at Step 8 — expected to partially fail. + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +- [ ] **Step 1: Delete `HandlerOutcome` enum and its `impl` block (~lines 62-78)** + +Delete from the doc comment `/// Result of routing a request...` through the closing `}` of `impl HandlerOutcome`. + +- [ ] **Step 2: Delete `legacy_main()` function (~lines 346-431)** + +Delete from the doc comment `/// Handles a request using the original Fastly-native entry point...` through the closing `}` of `legacy_main`. This includes the `// TODO: delete after Phase 5...` comment on line 345. + +- [ ] **Step 3: Delete the three FALLBACK\_\* constants and `build_ja4_debug_response()` (~lines 433-479)** + +Delete: + +```rust +const FALLBACK_UNAVAILABLE: &str = "unavailable"; +const FALLBACK_NOT_SENT: &str = "not sent"; +const FALLBACK_NONE: &str = "none"; +``` + +And the entire `build_ja4_debug_response()` function and doc comment. + +- [ ] **Step 4: Delete `route_request()` function (~lines 481-591)** + +Delete from `async fn route_request(` through its closing `}`. + +- [ ] **Step 5: Delete `resolve_publisher_response()` (~lines 593-612)** + +Delete the non-buffered `fn resolve_publisher_response` — NOT `resolve_publisher_response_buffered`. + +- [ ] **Step 6: Delete `finalize_response()` thin wrapper (~lines 650-652)** + +Delete: + +```rust +fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { + apply_finalize_headers(settings, geo_info, response); +} +``` + +- [ ] **Step 7: Delete `http_error_response()` (~lines 654-666)** + +Delete from `fn http_error_response(` through its closing `}`. + +- [ ] **Step 8: Check current state (expected to partially fail)** + +```bash +cargo check -p trusted-server-adapter-fastly 2>&1 | grep "^error" | head -15 +``` + +Expected errors: `main()` still calls `legacy_main()` and canary functions. These are fixed in Tasks 3 and 4. + +--- + +### Task 3: Delete canary flag plumbing from `main.rs` + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +- [ ] **Step 1: Delete `EDGEZERO_ENABLED_KEY` and `EDGEZERO_ROLLOUT_PCT_KEY` constants (~lines 55-56)** + +Delete: + +```rust +const EDGEZERO_ENABLED_KEY: &str = "edgezero_enabled"; +const EDGEZERO_ROLLOUT_PCT_KEY: &str = "edgezero_rollout_pct"; +``` + +- [ ] **Step 2: Delete `parse_edgezero_flag()` with its doc comment** + +- [ ] **Step 3: Delete `parse_rollout_pct()` with its doc comment** + +- [ ] **Step 4: Delete `fnv1a_bucket()` with its doc comment** + +- [ ] **Step 5: Delete `canary_routes_to_edgezero()` with its doc comment** + +- [ ] **Step 6: Delete `is_edgezero_enabled()` with its doc comment** + +- [ ] **Step 7: Delete `read_rollout_pct()` with its doc comment** + +--- + +### Task 4: Simplify `main()` and rewrite `edgezero_main()` + +> After this task, `main.rs` compiles cleanly. The remaining tasks clean up other files. + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +- [ ] **Step 1: Replace `main()` body** + +Replace the entire current `fn main()` (including its doc comment) with the simplified version from the "Simplified `main()`" section above. + +- [ ] **Step 2: Replace `edgezero_main()` signature and update its body** + +- Change signature from `fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle)` to `fn edgezero_main(mut req: FastlyRequest)` +- Add the `open_trusted_server_config_store()` call at the top (with error handling) +- Remove the old dispatch-succeeded/failed comment about logger initialization avoiding double-init +- Add the new comment from the target state above + +Replace `open_trusted_server_config_store()` doc comment with the simplified version above. + +- [ ] **Step 3: Verify compiles** + +```bash +cargo check -p trusted-server-adapter-fastly +``` + +Expected: PASS or unused-import warnings only (cleaned up in Task 5). + +- [ ] **Step 4: Run Fastly tests** + +```bash +cargo test-fastly +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Delete legacy_main, canary routing, and flag plumbing + +Remove legacy_main(), route_request(), HandlerOutcome, and all flag-reading +machinery (edgezero_enabled / edgezero_rollout_pct). Entry point is now +a direct trampoline to edgezero_main(), which opens the config store +internally. Part of #501 legacy entry point cleanup." +``` + +--- + +### Task 5: Delete legacy-only functions from `compat.rs` and delete `error.rs` + +> Both files have functions that were only ever called from `legacy_main()` (now deleted). +> `error.rs` is entirely legacy-only and can be deleted. `compat.rs` has three survivors. + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/compat.rs` +- Delete: `crates/trusted-server-adapter-fastly/src/error.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (remove `mod error;` and its import) + +- [ ] **Step 1: Delete `build_http_request()` and `from_fastly_request()` from `compat.rs`** + +Delete lines 11-38 — the private `build_http_request()` helper and the `from_fastly_request()` function with its doc comment. + +- [ ] **Step 2: Delete `to_fastly_response_skeleton()` from `compat.rs`** + +Delete lines 74-85 — the function and its doc comment. + +- [ ] **Step 3: Update `compat.rs` module doc comment** + +Replace: + +```rust +//! Compatibility bridge between `fastly` SDK types and `http` crate types. +//! +//! Contains only the functions used by the legacy `main()` entry point. +//! Relocated from `trusted-server-core` as part of removing all `fastly` crate +//! imports from the core library. +``` + +With: + +```rust +//! Compatibility bridge between `fastly` SDK types and `http` crate types. +``` + +- [ ] **Step 4: Delete `error.rs`** + +```bash +git rm crates/trusted-server-adapter-fastly/src/error.rs +``` + +- [ ] **Step 5: Remove `mod error;` from `main.rs` (~line 41)** + +Delete the line: + +```rust +mod error; +``` + +- [ ] **Step 6: Remove `use crate::error::to_error_response;` from `main.rs` (~line 50)** + +Delete the line: + +```rust +use crate::error::to_error_response; +``` + +- [ ] **Step 7: Verify compiles** + +```bash +cargo check -p trusted-server-adapter-fastly +``` + +Expected: PASS or unused-import warnings only. + +- [ ] **Step 8: Run Fastly tests** + +```bash +cargo test-fastly +``` + +Expected: PASS. The two `compat.rs` tests still run (they test the surviving functions). + +- [ ] **Step 9: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/compat.rs +git add crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Delete legacy-only compat functions and error module + +Remove from_fastly_request(), to_fastly_response_skeleton(), and their +build_http_request() helper from compat.rs — all were only called from +legacy_main(). Delete error.rs entirely (to_error_response() was its only +export; only caller was legacy_main()). Part of #501." +``` + +--- + +### Task 6: Delete canary tests and strip all unused imports from `main.rs` + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +- [ ] **Step 1: Delete canary-related test functions from `mod tests`** + +Delete these 14 test functions: + +1. `parses_true_flag_values` +2. `rejects_non_true_flag_values` +3. `parses_valid_rollout_percentages` +4. `rejects_invalid_rollout_percentages` +5. `bucket_is_in_range_0_to_99` +6. `bucket_is_deterministic` +7. `bucket_matches_known_fnv1a_vector` +8. `bucket_distributes_across_range` +9. `empty_key_bucket_is_valid` +10. `rollout_zero_routes_all_to_legacy` +11. `rollout_hundred_routes_all_to_edgezero` +12. `rollout_fifty_routes_exactly_half_of_bucket_space` +13. `rollout_one_routes_exactly_one_bucket` +14. `ja4_debug_response_uses_plain_text_and_fallback_values` + +Also delete `use fastly::mime;` from the test `use` block — only needed for `ja4` test. + +- [ ] **Step 2: Delete unused top-level imports (from the "Imports to remove" section above)** + +Delete these entire `use` lines: + +- `use trusted_server_core::auction::endpoints::handle_auction;` +- `use trusted_server_core::auction::AuctionOrchestrator;` +- `use trusted_server_core::auth::enforce_basic_auth;` +- `use trusted_server_core::platform::RuntimeServices;` +- `use trusted_server_core::request_signing::{...};` + +Trim these lines: + +```rust +// Was: +use crate::app::{build_state, runtime_services_for_consent_route, TrustedServerApp}; +// Change to: +use crate::app::TrustedServerApp; + +// Was: +use crate::platform::{build_runtime_services, FastlyPlatformGeo}; +// Change to: +use crate::platform::FastlyPlatformGeo; + +// Was: +use edgezero_core::http::{header, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse}; +// Change to: +use edgezero_core::http::{header, HeaderValue, Response as HttpResponse}; + +// Was: +use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; +// Change to: +use trusted_server_core::error::TrustedServerError; + +// Delete entire line: +use trusted_server_core::proxy::{handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign}; + +// Was: +use trusted_server_core::publisher::{handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, OwnedProcessResponseParams, PublisherResponse}; +// Change to: +use trusted_server_core::publisher::{stream_publisher_body, OwnedProcessResponseParams, PublisherResponse}; +``` + +- [ ] **Step 3: Verify no unused-import warnings** + +```bash +cargo check -p trusted-server-adapter-fastly 2>&1 | grep "unused import" +``` + +Expected: no output. If any warnings remain, remove the flagged import. + +- [ ] **Step 4: Run Fastly tests** + +```bash +cargo test-fastly +``` + +Expected: PASS. Surviving tests: `health_response_*`, `response_was_finalized_by_middleware_strips_sentinel`, `entry_point_finalize_skips_geo_lookup_for_401`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Strip canary tests and unused imports from main.rs + +Remove 14 canary-routing test functions and the JA4 debug test. +Strip all imports that were only consumed by deleted code. Part of #501." +``` + +--- + +### Task 7: Delete `build_runtime_services` from Fastly `platform.rs` + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` + +- [ ] **Step 1: Find exact lines** + +```bash +grep -n "build_runtime_services\|fn noop_kv_store" crates/trusted-server-adapter-fastly/src/platform.rs +``` + +- [ ] **Step 2: Delete `pub fn build_runtime_services(...)` and its doc comment** + +- [ ] **Step 3: Delete `fn noop_kv_store()` test helper** + +Confirmed: `noop_kv_store()` is only used by the two `build_runtime_services` tests being deleted — safe to remove. + +- [ ] **Step 4: Delete the two test functions** + +Delete: + +- `fn build_runtime_services_client_info_is_none_without_tls()` +- `fn build_runtime_services_returns_cloneable_services()` + +- [ ] **Step 5: Update module-level doc comment** + +Find and remove the sentence "This module also provides `build_runtime_services`, a free function that..." from the module doc. + +- [ ] **Step 6: Verify compiles and tests pass** + +```bash +cargo check -p trusted-server-adapter-fastly && cargo test-fastly +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Remove build_runtime_services from Fastly platform module + +build_runtime_services() was the legacy-path per-request service factory. +The EdgeZero path uses build_per_request_services() in app.rs instead. +Remove the dead function, its noop_kv_store() test helper, and its tests. +Part of #501." +``` + +--- + +### Task 8: Update stale doc comments in `middleware.rs` and `app.rs` + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/middleware.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` + +- [ ] **Step 1: Find stale references in `middleware.rs`** + +```bash +grep -n "finalize_response\|route_request" crates/trusted-server-adapter-fastly/src/middleware.rs +``` + +- [ ] **Step 2: Fix module doc in `middleware.rs` (~line 4)** + +Remove the phrase `from the legacy [\`crate::finalize_response\`] and [\`crate::route_request\`]:`. Keep the description of what the middleware does; remove only the stale intra-doc links. + +- [ ] **Step 3: Fix fn-level doc in `middleware.rs` (~line 150)** + +Remove `Mirrors [\`crate::finalize_response\`] exactly` (or rephrase without the broken intra-doc link). + +- [ ] **Step 4: Fix `http_error()` doc in `app.rs` (~line 243)** + +Replace: + +```rust +/// Convert a [`Report`] into an HTTP [`Response`], +/// mirroring [`crate::http_error_response`] exactly. +/// +/// The near-identical function in `main.rs` is intentional: the legacy path +/// uses fastly HTTP types while this path uses `edgezero_core` types. +``` + +With: + +```rust +/// Converts a [`Report`] into an HTTP [`Response`]. +``` + +- [ ] **Step 5: Verify doc check** + +```bash +cargo doc --no-deps -p trusted-server-adapter-fastly 2>&1 | grep "warning\|error" | head -20 +``` + +Expected: no broken intra-doc link warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/middleware.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Remove stale legacy references from doc comments + +middleware.rs referenced deleted finalize_response() and route_request(). +app.rs referenced deleted http_error_response(). Part of #501." +``` + +--- + +### Task 9: Remove canary flag keys from `fastly.toml` + +**Files:** + +- Modify: `fastly.toml` + +- [ ] **Step 1: Remove the two flag keys and their comments** + +In `[local_server.config_stores.trusted_server_config.contents]` (around lines 46-56), remove: + +```toml + # "true" / "1" (case-insensitive) enable the EdgeZero path. Missing, + # unreadable, or any other value falls back to the legacy entry point. + # Keep "false" until EdgeZero reaches full functional parity with legacy. + edgezero_enabled = "false" + # Integer 0-100. Effective only when edgezero_enabled = "true". + # 0 -> all traffic to legacy (instant rollback — no deploy needed) + # 1-99 -> canary: clients whose fnv1a_bucket(client_ip) < this value go EdgeZero + # 100 -> all traffic to EdgeZero (full cutover) + # Key absent when edgezero_enabled = "true" is treated as 100 (full rollout). + # IMPORTANT: Set this to "0" in production BEFORE setting edgezero_enabled = "true". + edgezero_rollout_pct = "0" +``` + +The `[local_server.config_stores.trusted_server_config.contents]` block becomes empty (`{}`). The store declaration and the `[local_server.config_stores.trusted_server_config]` header stay — the store is still opened by `open_trusted_server_config_store()`. + +- [ ] **Step 2: Verify local config loads without error** + +```bash +cargo check -p trusted-server-adapter-fastly +``` + +Expected: PASS (fastly.toml is only read at runtime by Viceroy/Fastly, not by `cargo check`). + +- [ ] **Step 3: Commit** + +```bash +git add fastly.toml +git commit -m "Remove edgezero_enabled and edgezero_rollout_pct from fastly.toml + +The app no longer reads these config store keys. Remove them from the +local dev config store. The trusted_server_config store itself stays — +it is still opened by open_trusted_server_config_store(). Part of #501." +``` + +--- + +### Task 10: Run full CI gates + +**Files:** None (verification only) + +- [ ] **Step 1: Format check** + +```bash +cargo fmt --all -- --check +``` + +If it fails: `cargo fmt --all && git add -u && git commit -m "Format after legacy cleanup"` + +- [ ] **Step 2: Clippy — all adapter targets** + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: zero warnings/errors on all targets. + +- [ ] **Step 3: Test — all adapter targets** + +```bash +cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin +``` + +Expected: PASS. + +- [ ] **Step 4: Parity test** + +```bash +cargo test --manifest-path crates/integration-tests/Cargo.toml --test parity +``` + +Expected: PASS. + +- [ ] **Step 5: JS tests and format** + +```bash +cd crates/js/lib && npx vitest run && npm run format +``` + +Expected: PASS. + +- [ ] **Step 6: Docs format** + +```bash +cd docs && npm run format -- --check +``` + +Expected: PASS. + +- [ ] **Step 7: Confirm all clean before PR** + +```bash +git status +``` + +Expected: clean working tree. All changes committed. + +--- + +## Execution options + +**Plan complete and saved to `docs/superpowers/plans/2026-05-27-pr20-legacy-cleanup.md`.** + +**1. Subagent-Driven (recommended)** — Fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — Execute tasks in this session using `executing-plans`, batch with checkpoints + +Which approach? diff --git a/fastly.toml b/fastly.toml index ebf884a4f..02dbe72b3 100644 --- a/fastly.toml +++ b/fastly.toml @@ -27,10 +27,6 @@ build = """ key = "placeholder" data = "placeholder" - [[local_server.kv_stores.opid_store]] - key = "placeholder" - data = "placeholder" - [[local_server.kv_stores.creative_store]] key = "placeholder" data = "placeholder" @@ -45,6 +41,9 @@ build = """ key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01" data = '{"v":1,"created":1700000000,"last_seen":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US"}}' + [[local_server.kv_stores.consent_store]] + key = "placeholder" + data = "placeholder" [local_server.secret_stores] [[local_server.secret_stores.signing_keys]] key = "ts-2025-10-A" @@ -58,19 +57,6 @@ build = """ [local_server.config_stores.trusted_server_config] format = "inline-toml" [local_server.config_stores.trusted_server_config.contents] - # "true" / "1" (case-insensitive) enable the EdgeZero path. Missing, - # unreadable, or any other value falls back to the legacy entry point. - # Keep "false" until EdgeZero parity is verified end to end (issue #495). - # EC API routes and the EC identity lifecycle are ported; the remaining - # intentional deviations are documented in adapter-fastly/src/app.rs. - edgezero_enabled = "false" - # Integer 0-100. Effective only when edgezero_enabled = "true". - # 0 -> all traffic to legacy (instant rollback — no deploy needed) - # 1-99 -> canary: clients whose fnv1a_bucket(client_ip) < this value go EdgeZero - # 100 -> all traffic to EdgeZero (full cutover) - # Absent, invalid, or unreadable all fail safe to legacy (0), so deleting - # the key can never trigger a cutover. Set an explicit percentage to roll out. - edgezero_rollout_pct = "0" [local_server.config_stores.jwks_store] format = "inline-toml" diff --git a/scripts/generate-integration-viceroy-configs.sh b/scripts/generate-integration-viceroy-configs.sh index c18c9d877..761d06926 100755 --- a/scripts/generate-integration-viceroy-configs.sh +++ b/scripts/generate-integration-viceroy-configs.sh @@ -39,13 +39,5 @@ fi "$GENERATOR_BIN" \ --template "$TEMPLATE_PATH" \ --app-config "$APP_CONFIG_PATH" \ - --output "$CONFIG_DIR/viceroy-legacy.toml" \ - --edgezero-enabled false \ - --origin-url "$ORIGIN_URL" - -"$GENERATOR_BIN" \ - --template "$TEMPLATE_PATH" \ - --app-config "$APP_CONFIG_PATH" \ - --output "$CONFIG_DIR/viceroy-edgezero.toml" \ - --edgezero-enabled true \ + --output "$CONFIG_DIR/viceroy.toml" \ --origin-url "$ORIGIN_URL" diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index d758e3a86..ce5e64387 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -36,7 +36,7 @@ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh -GENERATED_VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy-legacy.toml" +GENERATED_VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" # --- Build Docker images --- echo "==> Building WordPress test container..." diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 22b97341f..96e492f40 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -59,7 +59,7 @@ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh -VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy-legacy.toml" +VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" echo "==> Building WordPress test container..." docker build -t test-wordpress:latest \