Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
d0b304e
Add design for closing out issue #852
prk-Jr Sep 15, 2026
960d5c2
Add implementation plan for the predicate split and cache observability
prk-Jr Sep 15, 2026
af22124
Correct the PR 1 plan and spec against the code
prk-Jr Sep 15, 2026
141537e
Name the missing test_observation helper in the plan
prk-Jr Sep 15, 2026
5a0c98d
Restructure issue #852 as a single pull request
prk-Jr Sep 15, 2026
b94df56
Add plan parts 2 and 3: probe, purge, and the readthrough gate
prk-Jr Sep 15, 2026
39402a5
Carry cache outcomes on auction telemetry
prk-Jr Sep 15, 2026
9840218
Declare cache outcome columns on the auction events datasource
prk-Jr Sep 15, 2026
95678c1
Add a publisher test harness with both a template cache and a telemet…
prk-Jr Sep 15, 2026
bb01f94
Split origin shareability out of template eligibility, and record it
prk-Jr Sep 15, 2026
acebfaa
Record the template-cache bypass reason from both sources
prk-Jr Sep 15, 2026
a8e5374
Drop template_cache_state from auction telemetry
prk-Jr Sep 15, 2026
abc3d79
Correct the documented CI gate list
prk-Jr Sep 15, 2026
071121f
Document the cache telemetry caveats and record two implementation fi…
prk-Jr Sep 15, 2026
a1a93d6
Narrow this branch to issue #852's own scope
prk-Jr Sep 15, 2026
1555f90
Apply review findings
prk-Jr Sep 15, 2026
54f0a9e
Add a portable HTTP client to the operator CLI
prk-Jr Sep 15, 2026
6b36645
Add a portable loop-accept fixture origin for probe tests
prk-Jr Sep 15, 2026
68e0c0e
Add ts origin probe-shareability
prk-Jr Sep 15, 2026
d9b6e95
Key template cache entries on the reader-facing URL as well
prk-Jr Sep 15, 2026
cc0ec9a
Add a surrogate-key purge to the template cache trait
prk-Jr Sep 15, 2026
8f423eb
Make the reader-url purge handle independent of query parameter order
prk-Jr Sep 16, 2026
4dcfbe2
Stop the probe confounding two of its own axes
prk-Jr Sep 16, 2026
66b824a
Fail the probe when a cache answered for the origin
prk-Jr Sep 16, 2026
f56a97d
Make the planning documents describe what was actually built
prk-Jr Sep 16, 2026
c1fbc96
Pin ESI mode and reader support as individually necessary
prk-Jr Sep 16, 2026
796354a
Add the admin cache-purge endpoint on Fastly
prk-Jr Sep 16, 2026
028f569
Answer cache purge with 501 on the non-Fastly adapters
prk-Jr Sep 16, 2026
501a3a5
Assert cache-purge parity across the three non-Fastly adapters
prk-Jr Sep 16, 2026
02dab06
Add ts cache purge, driving the service's own endpoint
prk-Jr Sep 16, 2026
c1b9aea
Add a purge leg to the local template-cache harness
prk-Jr Sep 16, 2026
8b11630
Model platform cache intent as one enum rather than two flags
prk-Jr Sep 16, 2026
d20ea24
Gate the origin cache bypass on shareability, not on the ad stack
prk-Jr Sep 16, 2026
9e023e1
Put the origin readthrough loosening behind an operator opt-in
prk-Jr Sep 16, 2026
ff66478
Stop a reader's own cache semantics from blocking origin readthrough
prk-Jr Sep 16, 2026
49fffbd
Document origin readthrough, its weaker guarantees, and its rollback
prk-Jr Sep 16, 2026
c5da1c1
Promote the shared template cache out of spike status
prk-Jr Sep 16, 2026
1039e03
Drop the unverified latency figure and record what was measured
prk-Jr Sep 16, 2026
5503673
Stop the probe judging a bot wall's challenge page
prk-Jr Sep 16, 2026
290fc73
Stop failing axes the origin honestly declares in Vary
prk-Jr Sep 16, 2026
7bdc3d6
Apply full-branch review findings
prk-Jr Sep 16, 2026
d854348
Merge branch 'main' into 852-template-and-origin-caching
prk-Jr Sep 19, 2026
f3884a0
Fix origin caching and operator safety checks
prk-Jr Sep 19, 2026
a547d44
Reject incomplete origin shareability evidence
prk-Jr Sep 19, 2026
2de90f3
Resolve caching probe and purge review findings
prk-Jr Sep 21, 2026
c09f9c2
Merge main and preserve cookie cache isolation
prk-Jr Sep 21, 2026
25778ba
Merge branch 'main' into 852-template-and-origin-caching
prk-Jr Sep 22, 2026
e58a8d9
Probe bot and prefetch representations, compare cached headers, and k…
prk-Jr Sep 22, 2026
b8461e9
Refuse unparseable purge URLs and limit readthrough to document requests
prk-Jr Sep 23, 2026
9f18354
Correct origin probe safety checks and address review feedback
prk-Jr Sep 24, 2026
1751342
Merge main and preserve caching and Next.js coverage
prk-Jr Sep 24, 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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ jobs:
- name: Run inline control harness
run: BID_DELAY=3 ./scripts/template-cache-local-test.sh inline

- name: Run cache purge harness
run: BID_DELAY=3 ./scripts/template-cache-local-test.sh purge

test-axum:
name: cargo test (axum native)
runs-on: ubuntu-latest
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 26 additions & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ enum NamedRouteHandler {
TrustedServerDiscovery,
VerifySignature,
AdminNotSupported,
CachePurgeNotSupported,
AdminEcNotSupported,
AdminEidsLookup,
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
Expand Down Expand Up @@ -324,7 +325,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];

fn named_routes() -> [NamedRoute; 16] {
fn named_routes() -> [NamedRoute; 17] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
Expand All @@ -349,6 +350,14 @@ fn named_routes() -> [NamedRoute; 16] {
primary_methods: &[Method::POST],
handler: NamedRouteHandler::AdminNotSupported,
},
// Every method, for the same reason as the Fastly adapter: a method this route
// does not claim falls through to the publisher with the caller's `Authorization`
// header still attached.
NamedRoute {
path: "/_ts/admin/cache/purge",
primary_methods: LEGACY_ADMIN_DENY_METHODS,
handler: NamedRouteHandler::CachePurgeNotSupported,
},
// Admin EC lookup routes. Registered explicitly (like the key routes
// above) so they never fall through to the publisher fallback, and
// they match `Settings::ADMIN_ENDPOINTS` for auth coverage.
Expand Down Expand Up @@ -449,6 +458,22 @@ fn named_route_handler(
NamedRouteHandler::VerifySignature => {
handle_verify_signature(&state.settings, &services, 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
// here" from "endpoint does not exist".
let body = edgezero_core::body::Body::from(
"Template cache purge is not supported on the Axum dev server.\n\
Use the Fastly adapter (via Viceroy or deployed) to purge.\n",
);
let mut resp = Response::new(body);
*resp.status_mut() = StatusCode::NOT_IMPLEMENTED;
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
Ok(resp)
}
NamedRouteHandler::AdminNotSupported => {
// Config/secret-store writes are backed by read-only env vars on the
// Axum dev server. Returning 501 is clearer than failing on the first
Expand Down
26 changes: 26 additions & 0 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,20 @@ fn admin_key_management_not_supported() -> Response {
response
}

fn cache_purge_not_supported() -> Response {
let body = edgezero_core::body::Body::from(
"Template cache purge is not supported on Cloudflare Workers.\n\
Use the Fastly adapter (via Viceroy or deployed) to purge.\n",
);
let mut response = Response::new(body);
*response.status_mut() = StatusCode::NOT_IMPLEMENTED;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}

fn admin_ec_lookup_not_supported() -> Response {
core_admin_ec_lookup_not_supported()
}
Expand Down Expand Up @@ -672,6 +686,18 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
router = router.route(path, Method::OPTIONS, page_bids_preflight.clone());
}

let cache_purge_unsupported =
make_handler(Arc::clone(&state), |_s, _services, _req| async move {
Ok(cache_purge_not_supported())
});
for method in publisher_fallback_methods() {
router = router.route(
"/_ts/admin/cache/purge",
method,
cache_purge_unsupported.clone(),
);
}

let legacy_admin_deny =
make_handler(Arc::clone(&state), |_s, _services, _req| async move {
Ok(legacy_admin_alias_denied())
Expand Down
20 changes: 12 additions & 8 deletions crates/trusted-server-adapter-cloudflare/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ fn is_hop_by_hop_response_header(name: &str, connection_tokens: &[String]) -> bo
}

/// Cache policy for the outbound Workers `fetch` derived from
/// [`PlatformHttpRequest::bypass_cache`].
/// [`PlatformHttpRequest::cache_intent`].
///
/// Workers subrequests are eligible for Cloudflare's cache by default, so an
/// ad-stack navigation could otherwise be satisfied from cache (or revalidated
Expand All @@ -262,8 +262,12 @@ enum OutboundCacheMode {
}

#[cfg(any(target_arch = "wasm32", test))]
fn outbound_cache_mode(bypass_cache: bool) -> OutboundCacheMode {
if bypass_cache {
fn outbound_cache_mode(
intent: &trusted_server_core::platform::PlatformCacheIntent,
) -> OutboundCacheMode {
// Workers has no surrogate-key concept, so `Shared` falls in with `Default`: let the
// runtime apply its own behavior rather than pretending to honor a key it cannot use.
if intent.is_bypass() {
OutboundCacheMode::NoStore
} else {
OutboundCacheMode::RuntimeDefault
Expand Down Expand Up @@ -305,7 +309,7 @@ impl CloudflareHttpClient {
));
}

let cache_mode = outbound_cache_mode(request.bypass_cache);
let cache_mode = outbound_cache_mode(&request.cache_intent);

let uri = request.request.uri().to_string();
// http::Method always stores uppercase; worker 0.7 implements From<String> only.
Expand Down Expand Up @@ -929,18 +933,18 @@ mod tests {
#[test]
fn outbound_cache_mode_maps_bypass_to_no_store() {
assert_eq!(
outbound_cache_mode(true),
outbound_cache_mode(&trusted_server_core::platform::PlatformCacheIntent::Bypass),
OutboundCacheMode::NoStore,
"bypass_cache should force the Workers `no-store` cache mode"
"a bypass intent should force the Workers `no-store` cache mode"
);
}

#[test]
fn outbound_cache_mode_leaves_default_when_not_bypassing() {
assert_eq!(
outbound_cache_mode(false),
outbound_cache_mode(&trusted_server_core::platform::PlatformCacheIntent::Default),
OutboundCacheMode::RuntimeDefault,
"requests without bypass_cache should keep the runtime default cache behavior"
"a default intent should keep the runtime default cache behavior"
);
}
}
78 changes: 75 additions & 3 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,9 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime
.config_store(Arc::new(FastlyPlatformConfigStore))
.secret_store(Arc::new(FastlyPlatformSecretStore))
.kv_store(Arc::clone(&state.default_kv_store))
// Spike-only (#1009). Constructed unconditionally, but only read when the
// assembly mode is a shared-template one — which defaults to Inline, so this
// is inert until an operator opts in.
// Constructed unconditionally, but only read when the assembly mode is a
// shared-template one — which defaults to Inline, so this is inert until an
// operator opts in.
.template_cache(Arc::new(crate::template_cache::FastlyTemplateCache::new()))
.template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler))
.backend(Arc::new(FastlyPlatformBackend))
Expand Down Expand Up @@ -577,6 +577,20 @@ async fn execute_named(
return Ok(run_batch_sync(&state, &services, req));
}

// An operator cache purge is not a reader request: running the EC lifecycle would
// attach finalization state and could ingest the operator's cookies into KV.
if matches!(handler, NamedRouteHandler::AdminCachePurge) {
let principal = trusted_server_core::auth::authenticated_username(&req);
let response = trusted_server_core::cache_purge::handle_cache_purge(
&services,
req,
principal.as_deref(),
)
.await
.unwrap_or_else(|error| http_error(&error));
return Ok(response);
}

// These diagnostics are read-only. Running the normal EC lifecycle would
// attach finalization state and could ingest request cookies into KV after
// the handler returns, violating that contract.
Expand Down Expand Up @@ -652,6 +666,9 @@ async fn run_named_route(
NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup => {
unreachable!("admin diagnostics should be handled before EC setup")
}
NamedRouteHandler::AdminCachePurge => {
unreachable!("cache purge should be handled before EC setup")
}
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
NamedRouteHandler::BatchSync => {
// Dispatched by execute_named before EC state is built.
Expand Down Expand Up @@ -1094,6 +1111,7 @@ enum NamedRouteHandler {
DeactivateKey,
AdminEcLookup,
AdminEidsLookup,
AdminCachePurge,
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
/// reach the publisher fallback (which would leak admin credentials).
LegacyAdminDenied,
Expand All @@ -1115,6 +1133,11 @@ struct NamedRoute {
handler: NamedRouteHandler,
}

/// Every method an admin route must claim to keep non-primary methods from falling
/// through to the publisher with the `Authorization` header still attached.
///
/// Named for the legacy `/admin/*` aliases it was introduced for, and reused by every
/// route with the same requirement here and in the Axum and Spin adapters.
const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::GET,
Method::POST,
Expand Down Expand Up @@ -1146,6 +1169,15 @@ const NAMED_ROUTES: &[NamedRoute] = &[
primary_methods: &[Method::POST],
handler: NamedRouteHandler::DeactivateKey,
},
// Every method is claimed, not just POST. A method this route did not claim would
// fall through to the publisher, and `enforce_basic_auth` leaves the `Authorization`
// header in place, so a GET would ship the shared admin credential to the origin.
// The handler answers the non-POST methods with 405 itself.
NamedRoute {
path: "/_ts/admin/cache/purge",
primary_methods: LEGACY_ADMIN_DENY_METHODS,
handler: NamedRouteHandler::AdminCachePurge,
},
// Admin EC lookup: the bare route reads the EC ID from the caller's
// `ts-ec` cookie; the parameterized route takes an explicit EC ID.
NamedRoute {
Expand Down Expand Up @@ -1939,6 +1971,40 @@ mod tests {
}
}

#[test]
fn cache_purge_claims_every_method_that_could_reach_the_publisher() {
// The guard this route exists behind. `enforce_basic_auth` authenticates on the raw
// path and leaves the `Authorization` header attached, so any method this route does
// not claim falls through to the publisher fallback carrying the shared admin
// credential to the origin. Asserted against the fallback list itself rather than a
// copy of it, so a method added there cannot quietly open a hole here.
let route = NAMED_ROUTES
.iter()
.find(|route| route.path == "/_ts/admin/cache/purge")
.expect("cache purge must be a named route");

for method in super::publisher_fallback_methods() {
assert!(
route.primary_methods.contains(&method),
"{method} /_ts/admin/cache/purge must be claimed, or it reaches the publisher \
with the admin credential attached"
);
}
assert!(matches!(route.handler, NamedRouteHandler::AdminCachePurge));
}

#[test]
fn cache_purge_has_no_legacy_unauthenticated_alias() {
// The production basic-auth regex is `^/_ts/admin`. An `/admin/...` spelling would
// not match it, so it must not exist at all.
assert!(
!NAMED_ROUTES
.iter()
.any(|route| route.path == "/admin/cache/purge"),
"an /admin-prefixed alias would sit outside the basic-auth regex"
);
}

#[test]
fn admin_ec_lookup_routes_are_registered() {
// Both lookup shapes must be explicitly routed to the admin EC
Expand Down Expand Up @@ -2893,6 +2959,12 @@ mod tests {
Ok(())
}

/// A no-op beyond succeeding: this double stores by cache key, so it cannot
/// resolve a surrogate key to entries the way the platform does.
async fn purge_url_surrogate_key(&self, _key: &str) -> Result<(), TemplateCacheError> {
Ok(())
}

async fn purge_all(&self) -> Result<(), TemplateCacheError> {
self.entries.lock().expect("should lock entries").clear();
Ok(())
Expand Down
Loading
Loading