Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
52c8eba
The permissions resolved for a request are readable at /_ts/permissions
jwrosewell Oct 8, 2026
5fb5f07
Core builds a masked view of the configuration it runs
jwrosewell Oct 8, 2026
3c72517
Every adapter serves the configuration at /_ts/config
jwrosewell Oct 8, 2026
16771b1
Core shows a reader what is held against their Edge Cookie
jwrosewell Oct 8, 2026
33282f0
Every adapter serves what is held against a reader at /_ts/data
jwrosewell Oct 8, 2026
52c4583
Merge branch 'review/8-module-context' into review/9-visibility-endpo…
Jamesr51d Oct 12, 2026
1c02104
The deploy check refuses what the load refuses
Jamesr51d Oct 12, 2026
a085a3c
The data page keeps its sandbox under an operator's headers
Jamesr51d Oct 12, 2026
bf58e02
Merge split/8-module-context into split/9-visibility-endpoints
Jamesr51d Oct 13, 2026
497d553
Merge split/8-module-context into split/9-visibility-endpoints
Jamesr51d Oct 13, 2026
3b23228
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 15, 2026
7b13b18
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 16, 2026
67042c1
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 16, 2026
8c070eb
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 16, 2026
aa745b3
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 16, 2026
e55d76b
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 16, 2026
57af2da
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 18, 2026
8371ac1
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 18, 2026
23355c0
The secret masking test prints the unmasked paths, never their values
jwrosewell Oct 18, 2026
2bdfecd
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 18, 2026
88a7de5
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 18, 2026
c17d50c
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 19, 2026
cc5386c
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 19, 2026
ef9bffc
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 19, 2026
246863f
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 19, 2026
c97aae9
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 28, 2026
3a7d4c6
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 28, 2026
3ec6f36
The configuration view's tests load documents that name a policy
jwrosewell Oct 28, 2026
41e9767
The inspection pages name the permission policy
jwrosewell Oct 28, 2026
2f8e4e3
The policy file refusal names the pattern as written
jwrosewell Oct 28, 2026
f410924
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 28, 2026
59b927d
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 28, 2026
dfb24ce
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 28, 2026
e1b6e44
Merge split/8-module-context into split/9-visibility-endpoints
jwrosewell Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ 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::config::{CONFIG_JSON_PATH, CONFIG_PAGE_PATH, handle_config};
use trusted_server_core::inspect::data::{DATA_PAGE_PATH, handle_data};
use trusted_server_core::inspect::permissions::{
PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH, handle_permissions,
};
use trusted_server_core::integrations::{
IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput,
};
Expand Down Expand Up @@ -465,6 +470,9 @@ fn fallback_handler(
enum NamedRouteHandler {
TrustedServerDiscovery,
VerifySignature,
Permissions,
Config,
Data,
AdminNotSupported,
CachePurgeNotSupported,
AdminEcNotSupported,
Expand Down Expand Up @@ -496,7 +504,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];

fn named_routes() -> [NamedRoute; 17] {
fn named_routes() -> [NamedRoute; 22] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
Expand All @@ -508,6 +516,35 @@ 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,
},
// The settings the deployment is running, masked, shown to anyone.
NamedRoute {
path: CONFIG_PAGE_PATH,
primary_methods: &[Method::GET],
handler: NamedRouteHandler::Config,
},
NamedRoute {
path: CONFIG_JSON_PATH,
primary_methods: &[Method::GET],
handler: NamedRouteHandler::Config,
},
// What is held against the request's own Edge Cookie. This adapter
// keeps no identity graph, so the page says nothing is held.
NamedRoute {
path: DATA_PAGE_PATH,
primary_methods: &[Method::GET],
handler: NamedRouteHandler::Data,
},
// 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.
Expand Down Expand Up @@ -629,6 +666,11 @@ fn named_route_handler(
NamedRouteHandler::VerifySignature => {
handle_verify_signature(&state.settings, &services, req)
}
NamedRouteHandler::Permissions => {
handle_permissions(&state.settings, &services, &req).await
}
NamedRouteHandler::Config => Ok(handle_config(&state.settings, &req)),
NamedRouteHandler::Data => handle_data(None, None, &req),
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
Expand Down
127 changes: 127 additions & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ 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"),
("GET", "/_ts/config"),
("GET", "/_ts/config.json"),
("GET", "/_ts/data"),
("POST", "/_ts/admin/keys/rotate"),
("POST", "/_ts/admin/keys/deactivate"),
("GET", "/_ts/admin/ec"),
Expand Down Expand Up @@ -669,6 +674,128 @@ 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 config_endpoint_answers_anyone_and_masks_secrets() {
for (path, content_type) in [
("/_ts/config.json", "application/json"),
("/_ts/config", "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"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("should read the body");
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("XXXX"),
"{path} should mask what it does not show"
);
assert!(
!text.contains("admin-pass"),
"{path} should not carry the admin password"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() {
for (opened_as_a_page, expected) in [(false, 403), (true, 200)] {
let mut svc = make_service();
let mut builder = Request::builder().method("GET").uri("/_ts/data");
if opened_as_a_page {
builder = builder
.header("sec-fetch-mode", "navigate")
.header("sec-fetch-dest", "document");
}
let req = builder
.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(),
expected,
"opened as a page: {opened_as_a_page}"
);
assert_eq!(
resp.headers()["cache-control"],
"no-store, private",
"should never be stored"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("should read the body");
assert_eq!(
String::from_utf8_lossy(&body).contains("keeps no record"),
opened_as_a_page,
"should say nothing is held to a browser opening the page, and to nothing else"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auction_endpoint_does_not_require_auth() {
let mut svc = make_service();
Expand Down
29 changes: 29 additions & 0 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ 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::config::{CONFIG_PATHS, handle_config};
use trusted_server_core::inspect::data::{DATA_PAGE_PATH, handle_data};
use trusted_server_core::inspect::permissions::{PERMISSIONS_PATHS, handle_permissions};
use trusted_server_core::integrations::{
IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput,
};
Expand Down Expand Up @@ -886,6 +889,32 @@ fn build_router(state: &Arc<AppState>) -> 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());
}

// The settings the deployment is running, masked, shown to anyone as
// a page and as data.
let config = make_handler(Arc::clone(&state), |s, _services, req| async move {
Ok(handle_config(&s.settings, &req))
});
for path in CONFIG_PATHS {
router = router.route(path, Method::GET, config.clone());
}

// What is held against the request's own Edge Cookie. This adapter
// keeps no identity graph, so the page says nothing is held.
let data = make_handler(Arc::clone(&state), |_s, _services, req| async move {
handle_data(None, None, &req)
});
router = router.route(DATA_PAGE_PATH, Method::GET, data);

let cache_purge_unsupported =
make_handler(Arc::clone(&state), |_s, _services, _req| async move {
Ok(cache_purge_not_supported())
Expand Down
102 changes: 102 additions & 0 deletions crates/trusted-server-adapter-cloudflare/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ 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"),
("GET", "/_ts/config"),
("GET", "/_ts/config.json"),
("GET", "/_ts/data"),
("POST", "/_ts/admin/keys/rotate"),
("POST", "/_ts/admin/keys/deactivate"),
("GET", "/_ts/admin/ec"),
Expand Down Expand Up @@ -574,6 +579,103 @@ 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 config_endpoint_answers_anyone_and_masks_secrets() {
for (path, content_type) in [
("/_ts/config.json", "application/json"),
("/_ts/config", "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"
);
let body = resp.into_body().into_bytes().unwrap_or_default();
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("XXXX"),
"{path} should mask what it does not show"
);
assert!(
!text.contains("admin-pass"),
"{path} should not carry the admin password"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() {
for (opened_as_a_page, expected) in [(false, 403), (true, 200)] {
let mut builder = request_builder().method("GET").uri("/_ts/data");
if opened_as_a_page {
builder = builder
.header("sec-fetch-mode", "navigate")
.header("sec-fetch-dest", "document");
}
let req = builder
.body(edgezero_core::body::Body::empty())
.expect("should build request");
let resp = route(test_router(), req).await;
assert_eq!(
resp.status().as_u16(),
expected,
"opened as a page: {opened_as_a_page}"
);
assert_eq!(
resp.headers()["cache-control"],
"no-store, private",
"should never be stored"
);
let body = resp.into_body().into_bytes().unwrap_or_default();
assert_eq!(
String::from_utf8_lossy(&body).contains("keeps no record"),
opened_as_a_page,
"should say nothing is held to a browser opening the page, and to nothing else"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auction_endpoint_does_not_require_auth() {
let router = test_router();
Expand Down
Loading
Loading