diff --git a/Cargo.lock b/Cargo.lock index b83a98f892..c6f0293e03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6616,6 +6616,7 @@ dependencies = [ "tokio", "toml", "trusted-server-core", + "trusted-server-integration-seam-probe", "trusted-server-js", "trusted-server-modules", "trusted-server-permission-signal-gpc", @@ -6681,6 +6682,7 @@ dependencies = [ "spin-sdk", "tokio", "trusted-server-core", + "trusted-server-integration-seam-probe", "trusted-server-js", "trusted-server-modules", "trusted-server-permission-signal-gpc", diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 0c857e6c21..295d24b4f5 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -15,8 +15,7 @@ //! reports internal request state over an unauthenticated route, serves a //! browser module that does nothing useful, and resolves location from static //! configuration rather than from the request. It is a development dependency -//! of the Axum and Fastly adapters only, so no adapter's production build -//! reaches it. +//! of the four adapters only, so no adapter's production build reaches it. use std::collections::BTreeMap; use std::net::IpAddr; @@ -27,7 +26,7 @@ use async_trait::async_trait; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::header::{self, HeaderValue}; -use http::{Request, Response}; +use http::{Method, Request, Response}; use serde::Deserialize; use serde_json::json; use trusted_server_core::auction::demand::{ @@ -79,6 +78,13 @@ pub const SEAM_PROBE_REPORT_PATH: &str = "/integrations/seam_probe/report"; /// Path suffix of the report route, relative to the proxy prefix. const REPORT_ROUTE_SUFFIX: &str = "/report"; +/// Path of the probe's route outside `/integrations/`, which the proxy +/// declares as written rather than beneath its prefix. +pub const SEAM_PROBE_OWN_PATH: &str = "/seam-probe/own-path"; + +/// Body the probe answers a `GET` of [`SEAM_PROBE_OWN_PATH`] with. +pub const SEAM_PROBE_OWN_BODY: &str = "answered by the seam probe"; + /// The browser module this registration carries, built outside /// `trusted-server-js`. pub const PROBE_JS: &str = include_str!("../js/probe.js"); @@ -97,7 +103,8 @@ pub const PROBE_JS_SHA256: &str = pub const SEAM_PROBE_COUNTRY_MESSAGE: &str = "`[testing.seam-probe] country` must be exactly two letters"; -/// Request header a caller sets to have the preparer count that request. +/// Request header a caller sets to have the preparer, and the handler of +/// [`SEAM_PROBE_OWN_PATH`], count that request. /// /// The header's value is a token the caller chooses, so tests running in /// parallel count into separate entries. @@ -112,6 +119,11 @@ pub const SEAM_PROBE_COUNT_HEADER: &str = "x-seam-probe-count"; static PREPARE_COUNTS: LazyLock>> = LazyLock::new(|| Mutex::new(BTreeMap::new())); +/// How many times the handler of [`SEAM_PROBE_OWN_PATH`] ran for each +/// counting token, so a caller can tell that a request never reached it. +static OWN_PATH_COUNTS: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + /// How many times the preparer ran for requests carrying `token` in /// [`SEAM_PROBE_COUNT_HEADER`]. /// @@ -120,9 +132,25 @@ static PREPARE_COUNTS: LazyLock>> = /// Panics when the counter's lock is poisoned. #[must_use] pub fn prepare_runs_for(token: &str) -> usize { - PREPARE_COUNTS + runs_for(&PREPARE_COUNTS, token) +} + +/// How many times the handler of [`SEAM_PROBE_OWN_PATH`] ran for requests +/// carrying `token` in [`SEAM_PROBE_COUNT_HEADER`]. +/// +/// # Panics +/// +/// Panics when the counter's lock is poisoned. +#[must_use] +pub fn own_path_runs_for(token: &str) -> usize { + runs_for(&OWN_PATH_COUNTS, token) +} + +/// The runs `counts` holds for `token`. +fn runs_for(counts: &Mutex>, token: &str) -> usize { + counts .lock() - .expect("should lock the seam probe prepare counts") + .expect("should lock a seam probe counter") .get(token) .copied() .unwrap_or(0) @@ -130,6 +158,12 @@ pub fn prepare_runs_for(token: &str) -> usize { /// Records one preparer run against the token the request carries, if any. fn count_prepare_run(request: &Request) { + count_run(&PREPARE_COUNTS, request); +} + +/// Records one run in `counts` against the token the request carries, if +/// any. +fn count_run(counts: &Mutex>, request: &Request) { let Some(token) = request .headers() .get(SEAM_PROBE_COUNT_HEADER) @@ -137,9 +171,7 @@ fn count_prepare_run(request: &Request) { else { return; }; - let mut counts = PREPARE_COUNTS - .lock() - .expect("should lock the seam probe prepare counts"); + let mut counts = counts.lock().expect("should lock a seam probe counter"); *counts.entry(token.to_string()).or_insert(0) += 1; } @@ -557,6 +589,24 @@ impl SeamProbeProxy { ); Ok(response) } + + /// Answers [`SEAM_PROBE_OWN_PATH`] and counts the run against the + /// request's token. A `HEAD` answer carries the headers a `GET` answer + /// carries and no body, which the handler leaves out itself because + /// nothing in core, the adapters or `EdgeZero` strips one. + fn own_path(&self, req: &Request) -> Response { + count_run(&OWN_PATH_COUNTS, req); + let body = if req.method() == Method::HEAD { + EdgeBody::empty() + } else { + EdgeBody::from(SEAM_PROBE_OWN_BODY) + }; + let mut response = Response::new(body); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")); + response + } } #[async_trait(?Send)] @@ -566,7 +616,11 @@ impl IntegrationProxy for SeamProbeProxy { } fn routes(&self) -> Vec { - vec![self.get(REPORT_ROUTE_SUFFIX)] + vec![ + self.get(REPORT_ROUTE_SUFFIX), + IntegrationEndpoint::get(SEAM_PROBE_OWN_PATH), + IntegrationEndpoint::new(Method::HEAD, SEAM_PROBE_OWN_PATH), + ] } async fn handle( @@ -574,6 +628,9 @@ impl IntegrationProxy for SeamProbeProxy { call: trusted_server_core::module_context::ModuleCall<'_>, req: Request, ) -> Result, Report> { + if req.uri().path() == SEAM_PROBE_OWN_PATH { + return Ok(call.inject_with(self, &req, Self::own_path)?); + } call.inject_with(self, req, Self::route)?.await } } @@ -1019,12 +1076,21 @@ mod tests { #[test] fn report_route_is_the_documented_path() { - let routes = SeamProbeProxy.routes(); + let routes: Vec<(Method, String)> = SeamProbeProxy + .routes() + .into_iter() + .map(|route| (route.method, route.path)) + .collect(); - assert_eq!(routes.len(), 1, "should register exactly one route"); assert_eq!( - routes[0].path, SEAM_PROBE_REPORT_PATH, - "should route the documented report path" + routes, + [ + (Method::GET, SEAM_PROBE_REPORT_PATH.to_owned()), + (Method::GET, SEAM_PROBE_OWN_PATH.to_owned()), + (Method::HEAD, SEAM_PROBE_OWN_PATH.to_owned()), + ], + "should route the documented report path, and the probe's own path for GET and \ + HEAD" ); } diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index ecd158bf25..2a065c1e82 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -835,6 +835,286 @@ async fn ec_module_generates_an_identifier_with_the_modules_prefix() { ); } +// --------------------------------------------------------------------------- +// A module's own endpoint, outside `/integrations/` +// --------------------------------------------------------------------------- + +/// The probe's route at a path of its own, reached through this adapter's +/// router, and the two things a module route can meet there, being another +/// route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use axum::body::Body as AxumBody; + use axum::http::Request; + use edgezero_adapter_axum::service::EdgeZeroAxumService; + use error_stack::Report; + use tower::{Service as _, ServiceExt as _}; + use trusted_server_adapter_axum::app::build_state_with_registrations; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + + use super::{PROBE_BLOCK, body_text, seam_probe, service_with, settings_with}; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: edgezero_core::http::Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(edgezero_core::http::Response::new( + edgezero_core::body::Body::from(OWN_MODULE_BODY), + )) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// Sends one request with `method` to `uri`, carrying the probe's + /// counting header when `token` is given. + async fn send( + service: &mut EdgeZeroAxumService, + method: &str, + uri: &str, + token: Option<&str>, + ) -> axum::response::Response { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder + .body(AxumBody::empty()) + .expect("should build request"); + + service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should respond") + } + + /// The response's `Content-Type`. + fn content_type(response: &axum::response::Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let mut service = service_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]); + + let response = send(&mut service, "GET", seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response).await, + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let mut service = service_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]); + + let response = send(&mut service, "HEAD", seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response).await, "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "axum-own-path-post"; + let mut service = service_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]); + + let response = send( + &mut service, + "POST", + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ) + .await; + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response).await, + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with( + r#" + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "#, + ); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let settings = settings_with( + r#" + [testing] + modules = ["own-module"] + "#, + ); + let mut service = service_with(settings, &[own_module(declaring_the_config_page)]); + + let page = send(&mut service, "GET", CONFIG_PAGE_PATH, None).await; + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page).await, OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&mut service, "POST", CONFIG_PAGE_PATH, None).await; + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted).await, OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + /// Config store that answers nothing, so a test can build [`RuntimeServices`] /// without a real platform. /// diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index a23764293e..9d10f994a1 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -50,3 +50,6 @@ base64 = { workspace = true } edgezero-core = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +# Test fixture only. It proves the integration seam from outside core and must +# never reach a production build, so it stays a dev-dependency. +trusted-server-integration-seam-probe = { path = "../testing/seam-probe" } diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index bd40340da0..dac49ac4a3 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -853,6 +853,328 @@ fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { ); } +// --------------------------------------------------------------------------- +// A module's own endpoint, outside `/integrations/` +// --------------------------------------------------------------------------- + +/// The seam probe's route at a path of its own, reached through this +/// adapter's router, and the two things a module route can meet there, being +/// another route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Request, Response, request_builder}; + use edgezero_core::router::RouterService; + use error_stack::Report; + use trusted_server_adapter_cloudflare::app::{ + TrustedServerApp, build_state_with_registrations, + }; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + use trusted_server_integration_seam_probe as seam_probe; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// The probe's own section and table. + const PROBE_BLOCK: &str = r#" + [testing] + modules = ["seam-probe"] + + [testing.seam-probe] + country = "ZZ" + "#; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(Response::new(Body::from(OWN_MODULE_BODY))) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// The route tests' settings with `extra` appended. + fn settings_with(extra: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "hmac" + + [ec.hmac] + passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true + + {extra} + "# + )) + .expect("should parse route test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) + } + + /// The router with the `builders` composed in. + fn router_with(extra: &str, builders: &[IntegrationBuilder]) -> RouterService { + TrustedServerApp::routes_with_registrations(settings_with(extra), builders) + .expect("should build a router from the composed builders") + } + + /// Sends one request with `method` to `path`, carrying the probe's + /// counting header when `token` is given. + async fn send( + router: &RouterService, + method: Method, + path: &str, + token: Option<&str>, + ) -> Response { + let mut builder = request_builder().method(method).uri(path); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder.body(Body::empty()).expect("should build request"); + router.oneshot(request).await.expect("should route request") + } + + /// The response's `Content-Type`. + fn content_type(response: &Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + /// The response's body as text. + fn body_text(response: Response) -> String { + let body = response.into_body().into_bytes().unwrap_or_default(); + String::from_utf8_lossy(&body).into_owned() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::GET, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::HEAD, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response), "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "cloudflare-own-path-post"; + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send( + &router, + Method::POST, + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ) + .await; + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with( + r#" + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "#, + ); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let router = router_with( + r#" + [testing] + modules = ["own-module"] + "#, + &[own_module(declaring_the_config_page)], + ); + + let page = send(&router, Method::GET, CONFIG_PAGE_PATH, None).await; + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&router, Method::POST, CONFIG_PAGE_PATH, None).await; + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 5b3dce2ad9..8ea5d2bd5d 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -401,6 +401,284 @@ fn duplicate_integration_id_is_rejected_naming_both_sources() { ); } +/// The probe's route at a path of its own, reached through this adapter's +/// router, and the two things a module route can meet there, being another +/// route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Request, Response, request_builder}; + use edgezero_core::router::RouterService; + use error_stack::Report; + use futures::executor::block_on; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + + use super::{ + HMAC_BLOCK, PROBE_BLOCK, body_text, build_state_with_registrations, router_with, + seam_probe, settings_with, + }; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(Response::new(Body::from(OWN_MODULE_BODY))) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// Sends one request with `method` to `path`, carrying the probe's + /// counting header when `token` is given. + fn send(router: &RouterService, method: Method, path: &str, token: Option<&str>) -> Response { + let mut builder = request_builder() + .method(method) + .uri(format!("https://test-publisher.com{path}")); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder.body(Body::empty()).expect("should build request"); + block_on(router.oneshot(request)).expect("should route request") + } + + /// The response's `Content-Type`. + fn content_type(response: &Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + /// A router with the probe selected. + fn probe_router() -> RouterService { + router_with( + settings_with(&format!("{HMAC_BLOCK}{PROBE_BLOCK}")), + &[seam_probe::builder()], + ) + } + + #[test] + fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let response = send( + &probe_router(), + Method::GET, + seam_probe::SEAM_PROBE_OWN_PATH, + None, + ); + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[test] + fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let response = send( + &probe_router(), + Method::HEAD, + seam_probe::SEAM_PROBE_OWN_PATH, + None, + ); + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response), "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[test] + fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "fastly-own-path-post"; + + let response = send( + &probe_router(), + Method::POST, + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ); + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with(&format!( + r#"{HMAC_BLOCK} + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "# + )); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[test] + fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let router = router_with( + settings_with(&format!( + r#"{HMAC_BLOCK} + [testing] + modules = ["own-module"] + "# + )), + &[own_module(declaring_the_config_page)], + ); + + let page = send(&router, Method::GET, CONFIG_PAGE_PATH, None); + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&router, Method::POST, CONFIG_PAGE_PATH, None); + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + /// A demand source a crate outside core supplies builds this adapter's state, /// and the same settings are refused without that crate's builder. #[test] diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 2bdab4f9d5..8b5c6b95f8 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -49,3 +49,6 @@ base64 = { workspace = true } edgezero-core = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +# Test fixture only. It proves the integration seam from outside core and must +# never reach a production build, so it stays a dev-dependency. +trusted-server-integration-seam-probe = { path = "../testing/seam-probe" } diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 41715343ab..f575149dac 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -735,6 +735,326 @@ async fn first_party_proxy_rebuild_get_is_routed() { ); } +// --------------------------------------------------------------------------- +// A module's own endpoint, outside `/integrations/` +// --------------------------------------------------------------------------- + +/// The seam probe's route at a path of its own, reached through this +/// adapter's router, and the two things a module route can meet there, being +/// another route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Request, Response, request_builder}; + use edgezero_core::router::RouterService; + use error_stack::Report; + use trusted_server_adapter_spin::app::{TrustedServerApp, build_state_with_registrations}; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + use trusted_server_integration_seam_probe as seam_probe; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// The probe's own section and table. + const PROBE_BLOCK: &str = r#" + [testing] + modules = ["seam-probe"] + + [testing.seam-probe] + country = "ZZ" + "#; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(Response::new(Body::from(OWN_MODULE_BODY))) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// The route tests' settings with `extra` appended. + fn settings_with(extra: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "hmac" + + [ec.hmac] + passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true + + {extra} + "# + )) + .expect("should parse route test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) + } + + /// The router with the `builders` composed in. + fn router_with(extra: &str, builders: &[IntegrationBuilder]) -> RouterService { + TrustedServerApp::routes_with_registrations(settings_with(extra), builders) + .expect("should build a router from the composed builders") + } + + /// Sends one request with `method` to `path`, carrying the probe's + /// counting header when `token` is given. + async fn send( + router: &RouterService, + method: Method, + path: &str, + token: Option<&str>, + ) -> Response { + let mut builder = request_builder().method(method).uri(path); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder.body(Body::empty()).expect("should build request"); + router.oneshot(request).await.expect("should route request") + } + + /// The response's `Content-Type`. + fn content_type(response: &Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + /// The response's body as text. + fn body_text(response: Response) -> String { + let body = response.into_body().into_bytes().unwrap_or_default(); + String::from_utf8_lossy(&body).into_owned() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::GET, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::HEAD, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response), "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "spin-own-path-post"; + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send( + &router, + Method::POST, + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ) + .await; + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with( + r#" + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "#, + ); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let router = router_with( + r#" + [testing] + modules = ["own-module"] + "#, + &[own_module(declaring_the_config_page)], + ); + + let page = send(&router, Method::GET, CONFIG_PAGE_PATH, None).await; + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&router, Method::POST, CONFIG_PAGE_PATH, None).await; + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + // --------------------------------------------------------------------------- // First-party absolute-URI regression — Spin delivers a path-only request URI // (built from IncomingRequest::path_with_query), so the shared proxy/click/sign diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index f904b9fc35..d0bdaf996e 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -4493,6 +4493,121 @@ mod tests { ); } + /// A path outside `/integrations/` that the route clash test declares. + const OWN_ENDPOINT_PATH: &str = "/own-endpoint"; + + /// A proxy that declares the routes it holds and answers each with an + /// empty body. + struct DeclaredRoutes { + name: &'static str, + routes: Vec, + } + + #[async_trait(?Send)] + impl IntegrationProxy for DeclaredRoutes { + fn integration_name(&self) -> &'static str { + self.name + } + + fn routes(&self) -> Vec { + self.routes.clone() + } + + async fn handle( + &self, + _call: crate::module_context::ModuleCall<'_>, + _req: Request, + ) -> Result, Report> { + Ok(Response::new(EdgeBody::empty())) + } + } + + /// A registration under `id` whose proxy declares `methods` on + /// [`OWN_ENDPOINT_PATH`], one route for each. + fn declaring(id: &'static str, methods: &[Method]) -> Option { + let routes = methods + .iter() + .map(|method| IntegrationEndpoint::new(method.clone(), OWN_ENDPOINT_PATH)) + .collect(); + Some( + IntegrationRegistration::builder(id) + .with_proxy(Arc::new(DeclaredRoutes { name: id, routes })) + .build(), + ) + } + + /// A test module, as its integration id, its module name and its build + /// function. + type RouteModule = ( + &'static str, + &'static str, + crate::integrations::IntegrationBuilderFn, + ); + + const GETS: RouteModule = ("gets", "testing.gets", |_| { + Ok(declaring("gets", &[Method::GET])) + }); + const ALSO_GETS: RouteModule = ("also-gets", "testing.also-gets", |_| { + Ok(declaring("also-gets", &[Method::GET])) + }); + const GETS_TWICE: RouteModule = ("gets-twice", "testing.gets-twice", |_| { + Ok(declaring("gets-twice", &[Method::GET, Method::GET])) + }); + const POSTS: RouteModule = ("posts", "testing.posts", |_| { + Ok(declaring("posts", &[Method::POST])) + }); + + /// Builds a registry from `modules`, with every one of them selected. + fn registry_selecting( + modules: &[RouteModule], + ) -> Result> { + let mut settings = crate::test_support::tests::create_test_settings(); + let mut builders = Vec::new(); + for &(id, module, register) in modules { + settings.select_module("testing", module); + builders.push( + crate::integrations::IntegrationBuilder::new( + id, + "route-clash-test", + register, + validate_nothing, + ) + .with_module_name(module), + ); + } + IntegrationRegistry::with_registrations(&settings, &builders) + } + + #[test] + fn two_routes_on_one_method_and_path_are_refused_when_the_registry_is_built() { + let expected = format!("Integration route registration failed for GET {OWN_ENDPOINT_PATH}"); + // Two modules declaring one route each, and one module declaring the + // route twice. + for modules in [&[GETS, ALSO_GETS][..], &[GETS_TWICE]] { + let Err(error) = registry_selecting(modules) else { + panic!("should refuse a second GET route on {OWN_ENDPOINT_PATH}"); + }; + let message = error.to_string(); + assert!( + message.contains(&expected), + "should name the method and the path: {message}" + ); + } + + let registry = + registry_selecting(&[GETS, POSTS]).expect("should accept two methods on one path"); + let routed_to = |method: &Method| { + registry + .find_route(method, OWN_ENDPOINT_PATH) + .map(|(_, integration)| *integration) + }; + assert_eq!( + (routed_to(&Method::GET), routed_to(&Method::POST)), + (Some("gets"), Some("posts")), + "should route each method to the module that declares it" + ); + } + /// Selects core's stand-in, which registers a deferred browser module. fn enable_deferred_module(settings: &mut Settings) { settings.select_module("testing", test_support::deferred_fixture::MODULE); diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 5b47769c6d..86a65ed823 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -19,7 +19,10 @@ Axum, or Spin SDK types. Build one `IntegrationRegistration` with only the hooks the feature needs. Use `with_deferred_js()` only for a separately loaded integration module and `without_js()` when another asset path owns delivery. Proxy routes should be -namespaced and bounded; do not introduce a general outbound proxy. +bounded, and sit beneath the integration's prefix unless the module serves an +endpoint of its own, as +[Serving an endpoint from a module](#serving-an-endpoint-from-a-module) +describes. Do not introduce a general outbound proxy. ## Compiling core-neutral fixture @@ -220,6 +223,51 @@ Streaming support is an adapter capability. Request checked `supports_streaming_responses()`; unsupported adapters must reject the request instead of silently buffering it. +## Serving an endpoint from a module + +A module can answer a path of its own, outside `/integrations/`, with nothing +added to core. `IntegrationProxy::routes()` may declare any path. +`IntegrationEndpoint::new` and `IntegrationEndpoint::get`, `post`, `put`, +`delete` and `patch` keep the path as written, while the proxy's own `get`, +`post`, `put`, `delete` and `patch` put `proxy_prefix()` in front of it. A +path ending in `/*` covers every path beneath it. + +```rust +fn routes(&self) -> Vec { + vec![ + // Beneath the prefix, at /integrations/example/status. + self.get("/status"), + // As written. + IntegrationEndpoint::get("/example/status"), + IntegrationEndpoint::new(Method::HEAD, "/example/status"), + ] +} +``` + +A route answers only the method it declares. A `GET` route answers neither +`HEAD` nor `OPTIONS`, so a module that answers either declares a route for +that method too, and a request whose method has no module route on its path +is handled as if the module declared nothing there. Nothing in core, the +adapters or EdgeZero strips the body from an answer to `HEAD`, so the +handler of a `HEAD` route sends no body itself. + +Two routes on one method and path are refused when the registry is built, +whether one module declares both or two modules declare one each. The +message names the method and the path, as in +`Integration route registration failed for GET /example/status`. + +A module route is reached only when the adapter answers no route of its own +for that method and path. Every adapter answers `GET /_ts/config` itself, so +a module's `GET` route there never runs, while a `POST` of that path reaches +a module route as any other request does. The registry does not know the +adapter's routes, so it accepts a module route that can never run, and +nothing reports that it never runs. + +The steps that run before a module route are the adapter's request cycle, +the same steps a request for one of the publisher's own pages goes through. +A step that answers a request, such as a request filter turning it back, +answers in place of the route. + ## Browser and script guards Add a browser module only when browser state is required. Immediate modules @@ -251,7 +299,7 @@ points at the functions that do the work. | Part | Purpose | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Id | Names the integration in its routes under `/integrations//`, in its browser module and in diagnostics | +| Id | Names the integration in its browser module, in diagnostics and in the prefix `/integrations//` of its proxy routes, unless a route takes [a path of its own](#serving-an-endpoint-from-a-module) | | Source | The crate or package name, reported when two builders claim one id, so an operator can tell which crates collided | | Build function | Reads `Settings` and returns the registration. It is called only when a section selects the module | | Validate function | The integration's own deploy rules. It runs for every builder, selected or not | diff --git a/docs/superpowers/specs/2026-10-27-module-endpoints-design.md b/docs/superpowers/specs/2026-10-27-module-endpoints-design.md new file mode 100644 index 0000000000..895a508d47 --- /dev/null +++ b/docs/superpowers/specs/2026-10-27-module-endpoints-design.md @@ -0,0 +1,98 @@ +# Design Spec: A module outside core serves an endpoint of its own + +**Status:** Proposed, 2026-10-27. For the pull request [IABTechLab/trusted-server#1353](https://github.com/IABTechLab/trusted-server/pull/1353), "Prove a module outside core can serve its own endpoint", row 12 of the stack in [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777), for Tech Lab review. One commit removes what the pull request's attestation commits changed, returning the row's files to those of row 11, and the tests and the guide section below come after that commit. [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) carries every design document of the stack in one place. +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-08-27-integration-provider-seam-design.md` (row 7, the integration seam), `2026-10-12-diagnostic-headers-design.md` (row 15, which keeps `PlatformIdentity` in core), `2026-10-17-request-filters-on-every-adapter-design.md` (row 52) and `2026-10-17-edge-cookie-on-every-adapter-design.md` (row 53), in [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) +**Related PRs:** [IABTechLab/trusted-server#1353](https://github.com/IABTechLab/trusted-server/pull/1353) (row 12), [IABTechLab/trusted-server#1348](https://github.com/IABTechLab/trusted-server/pull/1348) (row 7, which opens the integration seam so that a vendor module can live outside core), [IABTechLab/trusted-server#1354](https://github.com/IABTechLab/trusted-server/pull/1354) (row 13, which closes the administration paths), [IABTechLab/trusted-server#1356](https://github.com/IABTechLab/trusted-server/pull/1356) (row 15), [IABTechLab/trusted-server#1410](https://github.com/IABTechLab/trusted-server/pull/1410) (row 52, the request filters on every adapter) and [IABTechLab/trusted-server#1411](https://github.com/IABTechLab/trusted-server/pull/1411) (row 53, the Edge Cookie on every adapter) +**Issues:** none. [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777) lists the stack +**Last updated:** 2026-10-27 + +> **Why this spec exists.** A module outside core can already answer a path of its own on every adapter, so a deployment that wants an endpoint core does not have, attestation among them, serves that endpoint from a module and core needs no interface for it. No test shows a module answering a path outside `/integrations/` on all four adapters, and no guide says how a module does so, what runs before its route or which paths it can never take. This change adds those tests and that guide section, and no production code in core or in any adapter changes. + +## 1. What already exists + +Each statement holds at this row and at the top of the stack, unless it names a row. + +**A module declares any path.** `IntegrationProxy::routes()` in `crates/trusted-server-core/src/integrations/registry.rs` returns the module's endpoints, each an `IntegrationEndpoint` holding a method and a path. `IntegrationEndpoint::new(method, path)` and the functions `IntegrationEndpoint::get`, `post`, `put`, `delete` and `patch` keep the path as written. The trait's own methods of the same five names put `proxy_prefix()` in front of the path, and `proxy_prefix()` is `/integrations/` unless the module overrides it. A path ending in `/*` covers every path beneath it. The registry keeps one router for each of `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD` and `OPTIONS`, and skips a route for any other method with a warning in the log. A `GET` route answers neither `HEAD` nor `OPTIONS`, so a module that answers either declares a route of its own for that method, as Sourcepoint declares `HEAD` and `OPTIONS` beside its `GET` and `POST` (`crates/cmp/sourcepoint/src/lib.rs`). A request whose method has no module route on its path is handled as if no module route existed. + +**Shipped modules already declare paths outside `/integrations/`.** Prebid declares a `GET` route for each of its `script_patterns`, which are `/prebid.js`, `/prebid.min.js`, `/prebidjs.js` and `/prebidjs.min.js` unless its settings name others (`crates/auction/prebid/src/lib.rs`). Didomi overrides `proxy_prefix()`, so its routes sit beneath the `proxy_path` its settings give in place of `/integrations/didomi/consent` (`crates/cmp/didomi/src/lib.rs`). Core's JS asset proxy declares a `GET` route at the first-party path of each asset that its settings proxy (`crates/trusted-server-core/src/integrations/js_asset_proxy.rs`). + +**A module outside core hands its routes to the adapter in its builder.** A crate outside core gives its `IntegrationBuilder` to the adapter. On Fastly it does so through `run_with` (`crates/trusted-server-adapter-fastly/src/lib.rs`), and from row 35 also through `run_with_addressing`. On Axum, Cloudflare and Spin it does so through `TrustedServerApp::routes_with_registrations`, or through `build_state_with_registrations` for a host that builds its own router around the state, and also through Axum's `routes_with_registrations_and_services` from row 25 and each adapter's `routes_with_addressing` from row 35. The integration guide shows `run_with`, `routes_with_registrations` and `build_state_with_registrations` under "How an adapter composes it in" (`docs/guide/integration-guide.md`), and the configuration guide names the two functions of row 35. A builder's registration, and so its routes, is built only when a section of the module's type names the module in its `module` or `modules` key, unless the builder registers from the auction plan, which runs whether or not a section selects the module (`IntegrationBuilder::with_plan_registration`). Prebid registers that way, whenever the settings carry Prebid's table. The module reads its own table at `[.]` with `Settings::module_config`. The settings read every top-level table they do not know as a module type's section (`TypeSections` in `crates/trusted-server-core/src/settings.rs`), so a module of a type core does not know needs nothing from core to be configured. + +**Two routes cannot share one method and path.** `IntegrationRegistry::with_plan_and_registrations` inserts each route into matchit's router for the route's method. The insert fails for a second route at the same method and path, from the same module or another, for a route matchit cannot tell apart from one already in the router, such as `/foo/{names}` beside `/foo/{name}`, and for a path matchit cannot parse, such as `/src/{*filepath}/x`, whose catch-all is not at the end. Each failure refuses the registry with the message `Integration route registration failed for `, followed by matchit's reason. A stock build, being an adapter built from the repository as it ships with the builders its own list names, can clash from its settings alone. Prebid's default `script_patterns` include `/prebid.js`, and core's JS asset proxy accepts an asset at that path, so when the settings carry Prebid's table and give the JS asset proxy an asset at `/prebid.js` to proxy, both modules declare `GET /prebid.js`. What a refusal does depends on how the builders arrive. + +- The stock entry points compose the shipped modules' builders, being `run` on Fastly and `Hooks::routes()` on Axum, Cloudflare and Spin, and Fastly's `run_with` adds a deployment's own builders to them. When the registry is refused, `startup_error_router` answers every request that reaches the router with the error. The adapter builds its state, and so its registry, for every request in a production Fastly build, a Cloudflare Worker and a Spin component, and once when the Axum dev server starts. A Fastly build with the `reusable-sandbox` feature builds once for each app-config key in each sandbox and keeps no failed build, so the next request for that key builds again. +- On Axum, Cloudflare and Spin a deployment's own builders arrive through `routes_with_registrations` and the other functions above, which return the error to the deployment's code and build no router. The integration guide's example passes the error on with `?`. + +**Where a module route runs.** Every adapter registers its own routes for the methods each serves, then a catch-all for `/` and every other path on the seven methods, whose handler is the publisher fallback. The module routes are not in the adapter's router. The fallback asks the registry for a route at the request's method and path (`IntegrationRegistry::has_route`) and hands the request to the module that declared the route (`IntegrationRegistry::handle_proxy`). At the top of the stack the fallback runs these steps in this order on all four adapters. + +1. The closed path check of row 13, which answers `404` for every path that begins with `/_ts/admin`, and for `/admin/keys` and the paths beneath it. +2. The modules' request preparers. +3. robots.txt, for a `GET` or `HEAD` of `/robots.txt` when `[robots-txt]` is present. +4. The Edge Cookie steps. Axum, Cloudflare and Spin classify the device here, inside `build_ec_context`, and on Fastly the entry point has classified the device before routing. The steps then read the request's Edge Cookie context and resolve the location and the permissions for it. They answer in place of the route when the request's `Cookie` header cannot be read as text, with `400`, or when the selected Edge Cookie module cannot be built for the request. +5. The request filters. A filter that answers stands in for the route. +6. tsjs, for a `GET` beneath `/static/tsjs=`. +7. The module route. +8. The asset routes, then the publisher's origin. + +At this row a module route meets four differences from that order, and the list covers only what differs for a module route. + +1. `deny_admin_diagnostic_fallback` answers a request beneath `/_ts/admin` or `/admin/keys` that reaches the fallback, and row 13 replaces that function with the closed path check. +2. No adapter has robots.txt code at this row. Rows 14 to 24 answer the file with a route of each adapter's own when `[robots-txt]` is present. The fallback answers the file ahead of the other steps from row 25 on Fastly and from row 52 on Axum, Cloudflare and Spin. +3. Axum, Cloudflare and Spin classify no device and run no request filter until row 52, so a module route there runs after the preparers, with an Edge Cookie context read from the request, and that context's errors answer in place of the route as in step 4. Fastly classifies the device at its entry point and runs the preparers, the Edge Cookie steps and the request filters before a module route, as at the top of the stack. +4. Until row 13 removes `[[handlers]]`, each adapter's router asks for the password of a `[[handlers]]` entry whose pattern covers the request's path before any route runs, a module route included. + +**What a module route is handed.** `handle_proxy` removes a caller's `x-ts-ec` header, creates an Edge Cookie identifier for a page navigation that needs one, and calls the module's `handle` with a module call. At this row the call carries the request's method, host, scheme, path and query, the settings, the services, the connection and, from the Edge Cookie context, the permissions, the consent, the location, the device signals and the Edge Cookie identifier. From row 39 the call also carries the request's evidence (`5494af9ca`). A gated value, being the location, the device signals or the Edge Cookie identifier, is passed only to a route that declares and is granted every permission the value's producer declares (`IntegrationProxy::required_permissions`, none by default), and a value whose producer declares none is passed to every route. Nothing gates the route itself, so a route whose use needs a permission checks that the permission is granted. On Fastly the module's response then goes through the Edge Cookie's finalize step, which can write the cookie, and on Axum, Cloudflare and Spin the response goes through that step from row 53. + +**Requests a module route never sees.** Steps 2, 4 and 5 above keep from every module route a request that a preparer refuses, whose Edge Cookie step fails or that a request filter answers. Beyond those, a module route for a method and path never runs when something ahead of it answers the request. + +1. A route of the adapter's own for the same method and path, because the adapter's own route wins over the catch-all. That covers the named routes for the methods each serves, such as `GET /_ts/config` on every adapter, and `GET /health` on Axum and Spin. +2. A request the Fastly entry point answers before its router, being `GET /health`, `GET /_ts/debug/ja4` and, in a build with the `reusable-sandbox` feature, `GET /_ts/debug/sandbox`. +3. A closed path, which the fallback answers first, through `deny_admin_diagnostic_fallback` at this row and the closed path check from row 13. +4. A `GET` or `HEAD` of `/robots.txt` when `[robots-txt]` is present, from row 14, except on Axum, Cloudflare and Spin between rows 25 and 51, where the publisher path answers the file after the module routes. +5. A `GET` beneath `/static/tsjs=`, which the fallback serves as tsjs. +6. From row 35, the path a reader sent, where the deployment's request addressing rewrites the path before routing, because a module route is matched against the path the request continues with. + +Another method on a path of item 1 reaches the fallback as any other request does, so a module route for `POST /_ts/config` runs. The registry does not know the adapter's routes, so the registry accepts a module route on such a path, and nothing reports that the route never runs. + +## 2. What this change adds + +**A route outside `/integrations/` in the seam probe.** The seam probe is the test module that proves the integration seam from outside core, `crates/testing/seam-probe` at this row and `crates/testing/iabtl` from row 43. The probe gains the constant `SEAM_PROBE_OWN_PATH`, which is `/seam-probe/own-path`, and its proxy declares a route at that path for `GET` with `IntegrationEndpoint::get` and for `HEAD` with `IntegrationEndpoint::new(Method::HEAD, ...)`. The proxy's `handle` sends a request for that path to a handler of its own, `SeamProbeProxy::own_path`. The handler answers a `GET` with `200`, `Content-Type: text/plain` and the body `SEAM_PROBE_OWN_BODY`, a constant of the probe, and a `HEAD` with `200`, the same headers and an empty body, which the handler sends itself because nothing in core, the adapters or EdgeZero strips a body from a `HEAD` answer. The handler counts its runs against the token a request carries in `SEAM_PROBE_COUNT_HEADER`, as the preparer counts its own, and `own_path_runs_for` reads that count as `prepare_runs_for` reads the preparer's. `report_route_is_the_documented_path` changes, because it asserts that the probe declares one route, and so does the crate's own doc, which names the Axum and Fastly adapters as the only ones that depend on the probe. + +**Tests on all four adapters.** Each test runs through the adapter's own router, or through the build of the adapter's state where the test expects a refusal. The tests sit beside the probe's other tests on Fastly (`crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs`) and Axum (`crates/trusted-server-adapter-axum/tests/seam_probe.rs`), and in the route tests of Cloudflare and Spin (`tests/routes.rs` in each adapter), which gain the probe as a development dependency. + +1. A `GET` of `/seam-probe/own-path` answers `200` with `SEAM_PROBE_OWN_BODY`. +2. A `HEAD` of the path answers `200` with no body. +3. A `POST` of the path, which the probe does not declare, sent with a counting token, leaves `own_path_runs_for` at `0` for that token, and the answer is not the probe's. +4. The probe and a builder of the test's own that also declares `GET` on `/seam-probe/own-path` are refused together, and the error holds `Integration route registration failed for GET /seam-probe/own-path`. +5. A builder of the test's own that declares `GET` and `POST` on `/_ts/config` never runs for the `GET`, which the inspection page answers, and runs for the `POST`. + +**A section of the integration guide.** "Serving an endpoint from a module", in `docs/guide/integration-guide.md`, states only what holds at every row. It says that a module may declare any path through `routes()`, that two routes on one method and path are refused when the registry is built, that a module route is reached only when the adapter answers no route of its own for that method and path, and that the steps that run before a module route are the adapter's request cycle, the same steps a request for one of the publisher's own pages goes through. It does not list those steps, so rows 13, 14, 25, 35, 36, 39, 52 and 53, which change them, leave the section as it is. It tells a module author that a `HEAD` route sends no body, because nothing in core, the adapters or EdgeZero strips one, and that `HEAD` and `OPTIONS` each need a route of their own. The section amends the two places in the guide that tie a module's routes to its prefix, being the rule under "Choose the narrowest hook" that proxy routes should be namespaced and bounded, which is main's text, and the row of the builder table under "What the crate provides" that says the id names the integration's routes under `/integrations//`, which row 7 added. + +## 3. What this change does not add + +- **An early answer.** A module route runs in the publisher fallback, after the Edge Cookie steps and the request filters on every adapter that runs them, and no module route is answered ahead of those steps as the inspection endpoints are. On every adapter that runs request filters, each filter sees the request for a module's endpoint, is told through `RequestFilterInput::is_integration_route` that a module route takes the request, and can answer in the route's place. The stack's own bot filter, DataDome's protection filter, lets every module route through, because `is_request_protected` in `crates/bot-protection/datadome/src/protection.rs` returns false when `is_integration_route` is set. +- **A description of each adapter's request cycle.** No adapter's guide sets out the steps the adapter runs before a module route, at this row or at the top of the stack, and this change adds none. Section 1 gives them. +- **A change to consent or privacy.** No production code changes, so a module route is handed what section 1 says, a gated value still reaches only a route that declares and is granted every permission the value's producer declares, and the tests and the guide section add no new reader of a request. +- **Attestation.** Core and the adapters gain no type, setting or route for attestation. A deployment that wants attestation serves it from a module of its own, as section 1 describes. +- **A platform identity.** `PlatformIdentity` is what an adapter reports about the platform it runs on, being a name, a service id, a service version, a staging flag and the location that answered. The type stays in core. Row 15 ([IABTechLab/trusted-server#1356](https://github.com/IABTechLab/trusted-server/pull/1356)) adds `PlatformIdentity` to the platform module, `crates/trusted-server-core/src/platform/identity.rs`, with `PlatformIdentity::named` and the `UNKNOWN` constant, and hands the identity to page middleware so that core's `service_version` middleware can write `X-TS-Service-Version`. Each adapter reports its platform with `IntegrationRegistry::with_platform` when it builds its state. Fastly reports through `fastly_platform_identity`, which reads `FASTLY_SERVICE_ID`, `FASTLY_SERVICE_VERSION`, `FASTLY_IS_STAGING` and `FASTLY_POP`, and the other three report their name only. + +## 4. Tests + +- **Added.** The five checks of section 2 on each of the four adapters, and `report_route_is_the_documented_path` amended for the probe's three routes. +- **Already in the stack.** `proxy_route_reports_the_modules_geo_and_that_the_preparer_ran`, on Fastly and Axum, shows a route of the probe beneath `/integrations/seam_probe` reached through the adapter's router after the preparers. From row 52, `a_filter_s_request_header_reaches_the_route_and_its_effect_is_written_last`, on Axum, Cloudflare and Spin, shows a module route seeing a request header that a filter added. +- **Not tested.** No test declares a module route beneath a closed path, at `/robots.txt` or beneath `/static/tsjs=`. + +## Revision record + +| Date | Change | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-27 | First draft. | +| 2026-10-27 | Fixes from review round 1. What each adapter does at startup when two routes clash, and the other routes matchit refuses, the shipped modules that declare paths outside their prefix, what a module call carries at this row and from row 39 and what a gated value needs, where each adapter classifies the device and when the Edge Cookie step answers, the requests a module route never sees, the request filters and DataDome, the guide section and the two places it amends, the probe's handler and counter with five tests, the platform identity and the header. | +| 2026-10-27 | Fixes from review round 2. What a refusal does on the stock entry points and on a deployment's own, with a clash from settings alone, two routes on one method and path in place of two modules on one path, every function that takes outside builders, when each adapter builds its state, the plan registration, the shipped modules' paths outside `/integrations/`, the steps that keep a request from a module route and the rows that change them, and `HEAD` bodies. | + +## Review log + +1. **Round 1.** A review in a fresh session, against `split/12-attestation`. Issues found, seven of them blocking. The seven were that the design said an adapter whose registry is refused serves its startup error, when on Axum, Cloudflare and Spin a deployment's own builders arrive through `routes_with_registrations`, which hands the error back to the deployment's code, that it said a module call carries the request's evidence, which only row 39 adds, that the steps before a module route left out device classification and did not say that the Edge Cookie step can answer in the route's place, that it said a bot filter can answer for a module's endpoint without saying that the stack's own DataDome filter lets every module route through, that the new guide section would contradict the two places in the guide that tie a module's routes to its prefix, that the section would go out of date as later rows change those steps, and that the planned `POST` check could not be built where the design put it. This revision says what each adapter does when two routes clash, what a module call carries at this row and from row 39, where each adapter classifies the device and when the Edge Cookie step answers, which requests a module route never sees, and that every request filter is told when a module route takes the request. The guide section now states only what holds at every row and amends those two places, and the seam probe gains a handler that counts its runs, so the `POST` check asserts that the handler never ran. +2. **Round 2.** A review in a fresh session, against `split/12-attestation`, of the round 1 fixes and the new text. Issues found, two of them blocking. The two were that the design said the stock `Hooks::routes()` takes no builders from outside core, when the stock list holds builders from crates outside core, such as Prebid and Didomi, and did not say what a clash does there, and that the guide section said two modules on one path are refused, when the registry keeps one router for each method. This revision says that the stock entry points compose the shipped modules' builders and answer every request with the startup error when the registry is refused, gives a clash that a stock build reaches from its settings alone, at `/prebid.js`, and says that two routes on one method and path are refused. It also lists every function through which a deployment's own builders arrive, says when each adapter builds its state, and says that nothing in core, the adapters or EdgeZero strips the body from a `HEAD` answer. +3. **Round 3.** A review in a fresh session, against `split/12-attestation`, of the round 2 fixes and the whole text. None blocking. The stock entry points are now said to compose the shipped modules' builders and to answer every request with the startup error when the registry is refused, with a clash at `/prebid.js` that the settings alone reach, and the guide section says that two routes on one method and path are refused. The round read the row's code for the probe's own path, its handler and its counter, the five checks on each of the four adapters, the development dependencies that Cloudflare and Spin gain, the crate's own doc, the guide section and the two places it amends, the registry's one router for each of seven methods and its refusal, the plan registration, the Fastly entry point's early answers, the closed path check, Didomi's prefix, the functions through which a deployment's own builders arrive at the top of the stack, the two tests section 4 names there and row 15's platform identity, and found each as the design states it.