From 52c8eba78b3fcea5f79d8cac672904e28917ddd4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 20:10:50 +0100 Subject: [PATCH 01/11] The permissions resolved for a request are readable at /_ts/permissions A deployment answers `GET /_ts/permissions` with a page, and `/_ts/permissions.json` with the same information as data, with no credential. It shows the permissions resolved for the request that asks, the signals that produced them and the terms the data is held under, so a publisher can point a reader, a regulator or an auditor at it and nobody has to take anybody's word for what was decided. The answer is the evaluation a page receives as `window.tsjs.permissions`. `PermissionState::page_value` is now the one place that shape is spelled, and `page_json` writes it. The endpoint adds what a person asking why a permission is not set needs and a page does not: whether storage was explicitly withdrawn, which signal modules the publisher configured and which of them contributed a signal, and the version that answered. The handler is core's, `inspect::permissions::handle_permissions`, and all four adapters route to it, so the answer is the same on each, which a parity test checks. It resolves the request's state as a page's is resolved and writes nothing for the reader. On Fastly it answers before the Edge Cookie lifecycle, so no cookie is set and no identity row is written. The answer is never stored, and is readable from any origin so another site can render it. A request from another origin carries no cookies, so that site reads the resolution of a request with no stored signal. --- crates/trusted-server-adapter-axum/src/app.rs | 20 +- .../tests/routes.rs | 39 ++ .../src/app.rs | 11 + .../tests/routes.rs | 32 ++ .../trusted-server-adapter-fastly/src/app.rs | 63 ++++ crates/trusted-server-adapter-spin/src/app.rs | 26 +- .../tests/routes.rs | 30 ++ crates/trusted-server-core/src/inspect.rs | 112 ++++++ .../src/inspect/permissions.rs | 343 ++++++++++++++++++ crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/permissions.rs | 14 +- .../tests/parity.rs | 59 +++ docs/guide/api-reference.md | 66 +++- 13 files changed, 810 insertions(+), 6 deletions(-) create mode 100644 crates/trusted-server-core/src/inspect.rs create mode 100644 crates/trusted-server-core/src/inspect/permissions.rs diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index e46b3bf41a..4581f7b459 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -21,6 +21,9 @@ use trusted_server_core::ec::admin::{ use trusted_server_core::ec::module::ensure_module_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::inspect::permissions::{ + PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH, handle_permissions, +}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, }; @@ -447,6 +450,7 @@ fn fallback_handler( enum NamedRouteHandler { TrustedServerDiscovery, VerifySignature, + Permissions, AdminNotSupported, CachePurgeNotSupported, AdminEcNotSupported, @@ -478,7 +482,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 17] { +fn named_routes() -> [NamedRoute; 19] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -490,6 +494,17 @@ fn named_routes() -> [NamedRoute; 17] { primary_methods: &[Method::POST], handler: NamedRouteHandler::VerifySignature, }, + // What the deployment decided for the asking request, shown to anyone. + NamedRoute { + path: PERMISSIONS_PAGE_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, + NamedRoute { + path: PERMISSIONS_JSON_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, // Canonical admin key routes. These match `Settings::ADMIN_ENDPOINTS` // and the production basic-auth handler regex (`^/_ts/admin`), so they // are auth-gated under a production-shaped config. @@ -611,6 +626,9 @@ fn named_route_handler( NamedRouteHandler::VerifySignature => { handle_verify_signature(&state.settings, &services, req) } + NamedRouteHandler::Permissions => { + handle_permissions(&state.settings, &services, &req).await + } NamedRouteHandler::CachePurgeNotSupported => { // The Axum dev server has no template cache to purge. 501 rather // than a fallthrough 404, so a CMS webhook can tell "not supported diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index a5b04222dd..1447e83ee6 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -122,6 +122,8 @@ fn all_explicit_routes_are_registered() { let expected: &[(&str, &str)] = &[ ("GET", "/.well-known/trusted-server.json"), ("POST", "/verify-signature"), + ("GET", "/_ts/permissions"), + ("GET", "/_ts/permissions.json"), ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("GET", "/_ts/admin/ec"), @@ -665,6 +667,43 @@ async fn discovery_endpoint_does_not_require_auth() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_endpoint_answers_anyone_as_data_and_as_a_page() { + for (path, content_type) in [ + ("/_ts/permissions.json", "application/json"), + ("/_ts/permissions", "text/html; charset=utf-8"), + ] { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri(path) + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 200, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store", + "{path} is one request's own answer" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auction_endpoint_does_not_require_auth() { let mut svc = make_service(); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index f70cca9950..486f90daf0 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -27,6 +27,7 @@ use trusted_server_core::ec::admin::{ use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::inspect::permissions::{PERMISSIONS_PATHS, handle_permissions}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, }; @@ -927,6 +928,16 @@ fn build_router(state: &Arc) -> RouterService { router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); } + // What the deployment decided for the asking request, shown to anyone + // as a page and as data. Read only, so nothing is written for the + // reader. + let permissions = make_handler(Arc::clone(&state), |s, services, req| async move { + handle_permissions(&s.settings, &services, &req).await + }); + for path in PERMISSIONS_PATHS { + router = router.route(path, Method::GET, permissions.clone()); + } + let cache_purge_unsupported = make_handler(Arc::clone(&state), |_s, _services, _req| async move { Ok(cache_purge_not_supported()) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 896ea37fc7..323da4fad8 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -264,6 +264,8 @@ fn all_explicit_routes_are_registered() { let expected: &[(&str, &str)] = &[ ("GET", "/.well-known/trusted-server.json"), ("POST", "/verify-signature"), + ("GET", "/_ts/permissions"), + ("GET", "/_ts/permissions.json"), ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("GET", "/_ts/admin/ec"), @@ -569,6 +571,36 @@ async fn discovery_endpoint_does_not_require_auth() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_endpoint_answers_anyone_as_data_and_as_a_page() { + for (path, content_type) in [ + ("/_ts/permissions.json", "application/json"), + ("/_ts/permissions", "text/html; charset=utf-8"), + ] { + let req = request_builder() + .method("GET") + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + 200, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store", + "{path} is one request's own answer" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auction_endpoint_does_not_require_auth() { 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 73b81e16ed..afbb93a8c7 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -29,6 +29,7 @@ //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | +//! | GET | `/_ts/permissions` and `/_ts/permissions.json` | [`handle_permissions`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/_ts/api/v1/ec/resolve` | [`handle_ec_resolve`] | //! | POST | `/auction` | [`handle_auction`] | @@ -123,6 +124,9 @@ use trusted_server_core::ec::resolve::handle_ec_resolve; use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; +use trusted_server_core::inspect::permissions::{ + PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH, handle_permissions, +}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, @@ -807,6 +811,16 @@ async fn execute_named( return Ok(response); } + // Read only. The permissions are resolved for this request as a page's + // would be, then shown, and the Edge Cookie lifecycle never runs, so + // nothing is written for the reader. + if matches!(handler, NamedRouteHandler::Permissions) { + let response = handle_permissions(&state.settings, &services, &req) + .await + .unwrap_or_else(|error| http_error(&error)); + return Ok(response); + } + if let Err(report) = state.registry.prepare_request(&state.settings, &mut req) { return Ok(http_error(&report)); } @@ -888,6 +902,9 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::Permissions => { + unreachable!("the permissions are answered before EC setup") + } NamedRouteHandler::EcResolve => { // The resolve endpoint persists the identity-graph row before // creating, so it takes the same bot-gated graph as generation: an @@ -1328,6 +1345,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + Permissions, EcResolve, Auction, PageBids, @@ -1443,6 +1461,17 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + // What the deployment decided for the asking request, shown to anyone. + NamedRoute { + path: PERMISSIONS_PAGE_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, + NamedRoute { + path: PERMISSIONS_JSON_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, NamedRoute { path: "/_ts/api/v1/ec/resolve", primary_methods: &[Method::POST], @@ -2525,6 +2554,40 @@ mod tests { ); } + #[test] + fn dispatch_permissions_answers_as_data_and_as_a_page_and_writes_nothing() { + let router = test_router(); + + let data = route(&router, empty_request(Method::GET, "/_ts/permissions.json")); + assert_eq!(data.status(), StatusCode::OK, "the data form should answer"); + assert_eq!(data.headers()[header::CONTENT_TYPE], "application/json"); + assert_eq!(data.headers()[header::CACHE_CONTROL], "no-store"); + assert!( + data.headers().get(header::SET_COOKIE).is_none(), + "should write nothing for the reader" + ); + assert!( + data.extensions().get::().is_none(), + "should not run the Edge Cookie lifecycle for a read" + ); + let payload: serde_json::Value = + serde_json::from_slice(&data.into_body().into_bytes().unwrap_or_default()) + .expect("the data form should be JSON"); + for key in ["set", "awaiting", "signals", "tdls", "modules"] { + assert!( + payload.get(key).is_some(), + "the resolution should carry `{key}`" + ); + } + + let page = route(&router, empty_request(Method::GET, "/_ts/permissions")); + assert_eq!(page.status(), StatusCode::OK, "the page should answer"); + assert_eq!( + page.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + } + #[test] fn dispatch_clear_tester_is_disabled_by_default() { let router = test_router(); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 7c49117ab6..793ca59ac1 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -28,6 +28,9 @@ use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; +use trusted_server_core::inspect::permissions::{ + PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH, handle_permissions, +}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, }; @@ -375,10 +378,12 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 17] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 19] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), + (PERMISSIONS_PAGE_PATH, &[Method::GET]), + (PERMISSIONS_JSON_PATH, &[Method::GET]), ("/_ts/admin/keys/rotate", &[Method::POST]), ("/_ts/admin/keys/deactivate", &[Method::POST]), ("/_ts/admin/ec", &[Method::GET]), @@ -804,6 +809,23 @@ fn build_router(state: &Arc) -> RouterService { } }; + // /_ts/permissions and /_ts/permissions.json, what the deployment + // decided for the asking request, shown to anyone. Read only, so + // nothing is written for the reader. + let s = Arc::clone(&state); + let permissions_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = s.services_for_request(&ctx); + let req = ctx.into_request(); + Ok::( + handle_permissions(&s.settings, &services, &req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + // /verify-signature let s = Arc::clone(&state); let verify_handler = move |ctx: RequestContext| { @@ -1097,6 +1119,8 @@ fn build_router(state: &Arc) -> RouterService { }) .get("/.well-known/trusted-server.json", discovery_handler) .post("/verify-signature", verify_handler) + .get(PERMISSIONS_PAGE_PATH, permissions_handler.clone()) + .get(PERMISSIONS_JSON_PATH, permissions_handler) // Canonical admin key routes. These match `Settings::ADMIN_ENDPOINTS` // and the production basic-auth handler regex (`^/_ts/admin`), so they // are auth-gated under a production-shaped config. diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 654a6b751f..c34770b1cb 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -911,6 +911,36 @@ async fn admin_route_with_wrong_credentials_returns_401() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_endpoint_answers_anyone_as_data_and_as_a_page() { + for (path, content_type) in [ + ("/_ts/permissions.json", "application/json"), + ("/_ts/permissions", "text/html; charset=utf-8"), + ] { + let req = request_builder() + .method("GET") + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + 200, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store", + "{path} is one request's own answer" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn discovery_endpoint_does_not_require_auth() { let router = test_router(); diff --git a/crates/trusted-server-core/src/inspect.rs b/crates/trusted-server-core/src/inspect.rs new file mode 100644 index 0000000000..a99a6ffb6a --- /dev/null +++ b/crates/trusted-server-core/src/inspect.rs @@ -0,0 +1,112 @@ +//! What a deployment shows to anyone who asks, with no credential. +//! +//! A publisher can point a reader, a regulator or an auditor at these +//! addresses, and each sees what this deployment decided for their own +//! request without having to take anybody's word for it. Every address is +//! fixed under `/_ts`, so it is the same on every deployment. +//! +//! | Address | What it shows | +//! | --- | --- | +//! | [`permissions::PERMISSIONS_PAGE_PATH`] | The permissions resolved for the request, the signals that produced them and the terms the data is held under | +//! +//! An address answers as a page, and as data with `.json` added. +//! +//! Operator data is not here. It is in the platform's own stores, reached +//! with the platform's own authentication, and never through the publisher's +//! domain. + +use serde_json::Value; + +pub mod permissions; + +/// Renders `payload` as a page titled `title`. +/// +/// Plain and self-contained. It carries no publisher branding, loads nothing +/// and reads at any width, because a reader on a phone opens it as easily as +/// an engineer does. +#[must_use] +pub fn render_page(title: &str, payload: &Value) -> String { + let pretty = serde_json::to_string_pretty(payload).unwrap_or_else(|_| "{}".to_owned()); + format!( + "\n\ +\n\ +\n\ +\n\ +\n\ +\n\ +{title}\n\ +\n\ +\n\ +\n\ +
\n\ +

{title}

\n\ +

Add .json to this address for the same \ +information as data.

\n\ +
{body}
\n\ +
\n\ +\n\ +\n", + title = html_escape(title), + body = html_escape(&pretty), + ) +} + +/// Escapes `value` for HTML text content. +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn a_page_escapes_whatever_its_payload_and_title_carry() { + let html = render_page( + "A ", + &json!({ "value": "</pre><script>x()</script> & more" }), + ); + + assert!( + !html.contains("<script>x()"), + "should not let a value open a tag" + ); + assert!( + html.contains("</pre><script>x()</script> & more"), + "should show the value as text" + ); + assert!( + html.contains("<title>A <title>"), + "should escape the title as well" + ); + } + + #[test] + fn a_page_tells_the_reader_where_the_data_form_is() { + let html = render_page("Permissions", &json!({})); + + assert!( + html.contains(".json"), + "should say how to get the same information as data" + ); + assert!( + html.contains(""), + "should keep the page out of search indexes" + ); + } +} diff --git a/crates/trusted-server-core/src/inspect/permissions.rs b/crates/trusted-server-core/src/inspect/permissions.rs new file mode 100644 index 0000000000..8d0aa4adca --- /dev/null +++ b/crates/trusted-server-core/src/inspect/permissions.rs @@ -0,0 +1,343 @@ +//! The permissions resolved for the request being served, at +//! [`PERMISSIONS_PAGE_PATH`] and [`PERMISSIONS_JSON_PATH`]. +//! +//! The answer is the evaluation the page receives as +//! `window.tsjs.permissions`, offered at an address so client code can fetch +//! it, another front end can render it, and a person can read it. It is +//! resolved for the request that asks, exactly as a page's is, and nothing is +//! written for the reader, because the Edge Cookie lifecycle does not run +//! here. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{HeaderValue, Request, Response, StatusCode, header}; +use serde_json::{Value, json}; + +use crate::ec::EcContext; +use crate::error::TrustedServerError; +use crate::permissions::PermissionState; +use crate::platform::RuntimeServices; +use crate::settings::Settings; + +use super::render_page; + +/// The permissions page. +pub const PERMISSIONS_PAGE_PATH: &str = "/_ts/permissions"; + +/// The same resolution as data. +pub const PERMISSIONS_JSON_PATH: &str = "/_ts/permissions.json"; + +/// Both addresses, the page first. +pub const PERMISSIONS_PATHS: [&str; 2] = [PERMISSIONS_PAGE_PATH, PERMISSIONS_JSON_PATH]; + +/// The permission resolution for a request, as the endpoint shows it. +/// +/// It is the page shape of [`PermissionState::page_value`] with three things +/// added that a page has no use for and a person asking why a permission is +/// not set does. +/// +/// `storageWithdrawn` says whether the request explicitly withdrew device +/// storage, which is a different fact from storage not being set. +/// +/// `modules.contributed` names the signal modules that produced a signal used +/// in this resolution, and `modules.configured` names the ones the publisher +/// asked to run. A module in `configured` and not in `contributed` ran and +/// found nothing to work with, and reporting only the contributors would make +/// it look the same as a module that was never configured. When the publisher +/// named no modules, `configured` is `null` and not a list, because the +/// selection is then whatever the adapter offers and core does not know what +/// that is. +/// +/// `version` is the version of the core crate that answered. +#[must_use] +pub fn permissions_payload(state: &PermissionState, settings: &Settings) -> Value { + let mut contributed: Vec<&str> = state.signals().iter().map(|signal| signal.module).collect(); + contributed.sort_unstable(); + contributed.dedup(); + let configured = settings + .permission_signal + .modules + .as_ref() + .map(|names| json!(names)); + + let mut payload = state.page_value(); + if let Value::Object(fields) = &mut payload { + fields.insert("version".to_owned(), json!(env!("CARGO_PKG_VERSION"))); + fields.insert( + "storageWithdrawn".to_owned(), + json!(state.storage_withdrawn()), + ); + fields.insert( + "modules".to_owned(), + json!({ + "contributed": contributed, + "configured": configured, + }), + ); + } + payload +} + +/// The permissions page, or its JSON when `path` is +/// [`PERMISSIONS_JSON_PATH`], for a request whose permissions have been +/// resolved. +/// +/// Never stored, because each answer is the asking request's own, and +/// readable from any origin, so a page on another site can render it. A +/// request from another origin carries no cookies, so what that page reads is +/// the resolution of a request with no stored signal. +/// +/// # Panics +/// +/// Never in practice, because the status and every header are fixed, valid +/// values. +#[must_use] +pub fn permissions_response( + state: &PermissionState, + settings: &Settings, + path: &str, +) -> Response { + let payload = permissions_payload(state, settings); + let (content_type, body) = if path == PERMISSIONS_JSON_PATH { + ("application/json", payload.to_string()) + } else { + ( + "text/html; charset=utf-8", + render_page("Permissions", &payload), + ) + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) + .header(header::CACHE_CONTROL, HeaderValue::from_static("no-store")) + .header( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ) + .body(EdgeBody::from(body.into_bytes())) + .expect("should build permissions response") +} + +/// Answers [`PERMISSIONS_PAGE_PATH`] or [`PERMISSIONS_JSON_PATH`] for `req`. +/// +/// The permissions are resolved for this request as a page's would be, from +/// its signals and its location, and then shown. Nothing is created or +/// written for the reader. +/// +/// # Errors +/// +/// When the request's state cannot be read, which is when the selected Edge +/// Cookie module cannot be built or the `Cookie` header is not valid UTF-8, +/// the same as for a page. +pub async fn handle_permissions( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result, Report> { + let state = EcContext::read_from_request_resolving_geo(settings, req, services).await?; + Ok(permissions_response( + state.permissions(), + settings, + req.uri().path(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::permissions::{Permission, PermissionSet, ValidSignal}; + use crate::platform::test_support::noop_services; + use crate::tdl::Tdl; + use crate::test_support::tests::create_test_settings; + + fn state_with_signal() -> PermissionState { + PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)) + .with_signals(vec![ValidSignal::new("tcf", "tcf", "CPabc123")].into()) + .with_tdls( + vec![ + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ] + .into(), + ) + } + + fn body_of(response: Response) -> String { + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("the body should be text") + } + + /// A reader comes to see which signal produced the answer, so the signal + /// and the module that read it must both be there. + #[test] + fn the_payload_names_the_signal_and_the_module_that_read_it() { + let payload = permissions_payload(&state_with_signal(), &create_test_settings()); + + assert_eq!(payload["signals"][0]["module"], "tcf"); + assert_eq!(payload["signals"][0]["scheme"], "tcf"); + assert_eq!(payload["signals"][0]["value"], "CPabc123"); + assert_eq!(payload["modules"]["contributed"], json!(["tcf"])); + assert_eq!( + payload["tdls"], + json!(["https://terms.example.com/marketing/2.txt"]) + ); + assert_eq!(payload["set"], json!(["necessary.operations.storage"])); + assert_eq!(payload["storageWithdrawn"], json!(false)); + assert_eq!(payload["version"], json!(env!("CARGO_PKG_VERSION"))); + } + + /// The page shape is spelled once, on the state, so the endpoint cannot + /// drift from what a page is given. + #[test] + fn the_payload_holds_everything_the_page_is_given() { + let state = state_with_signal(); + let payload = permissions_payload(&state, &create_test_settings()); + + let page = state.page_value(); + for (key, value) in page.as_object().expect("the page shape is an object") { + assert_eq!( + &payload[key], value, + "`{key}` should be what the page is given" + ); + } + } + + /// A module that ran and found nothing and a module that was never + /// configured are different facts, and somebody asking why a permission + /// is not set needs to tell them apart. + #[test] + fn configured_and_contributed_modules_are_reported_separately() { + let mut settings = create_test_settings(); + settings.permission_signal.modules = Some(vec!["tcf".to_owned(), "gpc".to_owned()]); + + let payload = permissions_payload(&state_with_signal(), &settings); + + assert_eq!(payload["modules"]["contributed"], json!(["tcf"])); + assert_eq!(payload["modules"]["configured"], json!(["tcf", "gpc"])); + } + + /// With no modules named the selection is the adapter's, which core + /// cannot see, so it says nothing and does not invent a list. + #[test] + fn configured_is_null_when_the_publisher_named_no_modules() { + let mut settings = create_test_settings(); + settings.permission_signal.modules = None; + + let payload = permissions_payload(&state_with_signal(), &settings); + + assert!( + payload["modules"]["configured"].is_null(), + "should not guess which modules the adapter offers" + ); + } + + /// "No signal was found" is itself the answer somebody came for. + #[test] + fn a_resolution_with_no_signals_still_answers() { + let payload = permissions_payload(&PermissionState::default(), &create_test_settings()); + + assert_eq!(payload["signals"], json!([])); + assert_eq!(payload["set"], json!([])); + assert_eq!(payload["awaiting"], json!([])); + assert_eq!(payload["modules"]["contributed"], json!([])); + } + + #[test] + fn a_withdrawal_of_storage_is_shown_as_one() { + let state = PermissionState::default().with_storage_withdrawn(true); + + let payload = permissions_payload(&state, &create_test_settings()); + + assert_eq!(payload["storageWithdrawn"], json!(true)); + } + + #[test] + fn the_json_address_answers_data_and_the_page_address_a_page() { + let settings = create_test_settings(); + let state = state_with_signal(); + + let data = permissions_response(&state, &settings, PERMISSIONS_JSON_PATH); + assert_eq!(data.status(), StatusCode::OK); + assert_eq!(data.headers()[header::CONTENT_TYPE], "application/json"); + assert_eq!(data.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(data.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); + let parsed: Value = + serde_json::from_str(&body_of(data)).expect("the data form should be JSON"); + assert_eq!(parsed, permissions_payload(&state, &settings)); + + let page = permissions_response(&state, &settings, PERMISSIONS_PAGE_PATH); + assert_eq!( + page.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!(page.headers()[header::CACHE_CONTROL], "no-store"); + let html = body_of(page); + assert!(html.contains("

Permissions

"), "should be the page"); + assert!(html.contains("CPabc123"), "should show the signal"); + } + + /// The page carries a signal value straight from the request, so + /// anything that could close the surrounding tag is escaped. + #[test] + fn the_page_escapes_a_hostile_signal_value() { + let state = PermissionState::new(PermissionSet::none()) + .with_signals(vec![ValidSignal::new("tcf", "tcf", "