Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
49b2802
robots.txt is a middleware named for its path
jwrosewell Oct 11, 2026
71dbb17
The guides say robots.txt is made by its middleware
jwrosewell Oct 11, 2026
3a417af
Merge branch 'review/24-loading-cache' into review/25-robots-txt-midd…
Jamesr51d Oct 12, 2026
7b34f7c
Only a fetch entry on /robots.txt places the robots.txt middleware
Jamesr51d Oct 12, 2026
2f01b3c
Merge branch 'review/24-loading-cache' into review/25-robots-txt-midd…
Jamesr51d Oct 12, 2026
1380309
Merge 24-loading-cache into 25-robots-txt-middleware
Jamesr51d Oct 12, 2026
4a62705
Merge 24-loading-cache into 25-robots-txt-middleware
Jamesr51d Oct 12, 2026
895e66c
The robots.txt middleware takes what it prepared for its own phase
Jamesr51d Oct 12, 2026
58c3456
Merge 24-loading-cache into 25-robots-txt-middleware
jwrosewell Oct 13, 2026
5fb7c64
Merge 24-loading-cache into 25-robots-txt-middleware
jwrosewell Oct 13, 2026
b04af3d
Merge split/24-loading-cache into split/25-robots-txt-middleware
Jamesr51d Oct 13, 2026
c56c240
Merge split/24-loading-cache into split/25-robots-txt-middleware
Jamesr51d Oct 13, 2026
1624877
Merge 24-loading-cache into 25-robots-txt-middleware
jwrosewell Oct 13, 2026
4313ba6
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
7f7b58a
With nothing prepared the robots.txt file refuses every crawler
jwrosewell Oct 16, 2026
146b438
The robots.txt file takes the place of any answer it cannot replace
jwrosewell Oct 16, 2026
63f076c
A robots.txt contributor's failure is logged with each address cut to…
jwrosewell Oct 16, 2026
016c07d
The robots.txt middleware works on /robots.txt alone
jwrosewell Oct 16, 2026
56cf9bd
The origin is asked for the whole file a middleware needs
jwrosewell Oct 16, 2026
5266e31
Each adapter serves /robots.txt through the publisher path in a test
jwrosewell Oct 16, 2026
a632721
The changelog marks the removal of the robots.txt route as breaking
jwrosewell Oct 16, 2026
5e255bb
The guides say where the robots.txt file takes the origin's place
jwrosewell Oct 16, 2026
2d33b69
The test of the made robots.txt file covers a 304 the origin sends
jwrosewell Oct 16, 2026
6c80424
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
67e41cf
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
9a1e37f
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
c4d150e
Merge the review fixes into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
8eae00f
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
5f3b4e8
The Axum robots.txt test sends each request through a clone of its ro…
jwrosewell Oct 16, 2026
4be8f62
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
318530f
The robots.txt answers test names the type of one origin answer
jwrosewell Oct 16, 2026
eb3c92b
Ask robots.txt contributors for the host the request was addressed to
jwrosewell Oct 16, 2026
aefbfb1
Test on Axum that a forwarded host never reaches a robots.txt contrib…
jwrosewell Oct 16, 2026
0869a85
Answer robots.txt on Fastly before the Edge Cookie steps and the filters
jwrosewell Oct 16, 2026
5532183
Restore what the docs say of robots.txt's host and Fastly's order
jwrosewell Oct 16, 2026
559ecf0
The robots.txt failure test plants a word no function name holds
jwrosewell Oct 16, 2026
12e6686
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 16, 2026
76e0ec6
The Fastly robots.txt order test sends the Host header a request carries
jwrosewell Oct 16, 2026
5ce2940
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 18, 2026
1320bc6
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 18, 2026
13479a9
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 18, 2026
d4563e8
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 18, 2026
ba583e6
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 19, 2026
5eea731
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 19, 2026
9a1d5d6
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 19, 2026
95a9634
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 19, 2026
3fcaa91
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 28, 2026
bf0d958
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 28, 2026
288237b
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 28, 2026
51dccd1
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 28, 2026
bcbc92b
The Fastly robots.txt test's settings carry the sample policy
jwrosewell Oct 28, 2026
6b0a0f6
The Axum robots.txt test's settings carry the sample policy
jwrosewell Oct 28, 2026
531d2e9
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 28, 2026
b6176f4
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 28, 2026
fab1383
Merge split/24-loading-cache into split/25-robots-txt-middleware
jwrosewell Oct 28, 2026
a1fa414
Merge split/24-loading-cache into split/25-robots-txt-middleware
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Removed

- **Breaking:** The `/robots.txt` route on the four adapters, with `handle_robots_txt` and the route's own `Cache-Control: public, max-age=3600`. The file is made by the `robots_txt` middleware where an entry names it, and takes the deployment's cache policy for the path as every file does. A configuration with `[robots-txt]` and no `[[fetch]]` entry naming `robots_txt` on `/robots.txt` is refused when it loads, and a build from before this change refuses that entry, so the new configuration and the new build go live together.
- **Breaking:** HTTP Basic authentication is removed, with the `[[handlers]]` list that configured it. No request is asked for a password, and nothing is served beneath `/_ts/admin`. A configuration that still carries `[[handlers]]` is refused, with directions, when it is validated and when the service starts, so that no deployment believes a path is protected when it is not. A build before this change refuses a configuration without a handler covering `/_ts/admin`, and one with `[request_signing]` and no store ids, so no one configuration starts both builds: the new configuration and the new build go live together. A staged version (`ts deploy --staging`) reads the same configuration key from the config store it links, so a staging store of its own, written with `ts config push --staging`, lets the pair be checked together first. Put a password on a path of the publisher's own at its origin or in the hosting platform. The `handler_password` secret is no longer read, and its entries in `fastly.toml`, the Spin manifest and the smoke scripts are removed.
- **Breaking:** `POST /_ts/admin/cache/purge` is removed. It let anyone holding one shared password empty the service's cache over the publisher's own domain. Purge with `ts cache purge`, which asks the hosting platform. A request to the removed path is answered `404` by the server itself and is never forwarded to the origin.
- The Fastly adapter's management API client is removed. The deployed service no longer reads a Fastly API token from a secret store named `api-keys`, which it used to write its own config store and secret store when a key was rotated. A deployment that linked that store can unlink it and revoke the token. `fastly.toml` and the Viceroy template drop their `api-keys` entries.
Expand Down Expand Up @@ -93,6 +94,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `robots_txt`, core's own middleware for the file `[robots-txt]` describes, named in a `[[fetch]]` entry for `text/plain` on `/robots.txt`. Its preparation makes the file from the selected modules through their holding, and its action replaces the origin's file with it. The file is made in place of any other answer the origin gives to a `GET` or `HEAD`, such as a `404`, a page, an error or a redirect. The origin is asked for its whole file, without a crawler's conditional or range headers, and the middleware works on `/robots.txt` alone. A module that cannot answer with nothing held refuses the request with `503` and `Retry-After` before any document is processed. A section with no such entry, or with an entry naming the middleware on another path or media type, is refused when the settings load.
- A middleware may say which request paths it works on, with `Middleware::handles_path`, that it writes the whole document, with `writes_whole_document`, so core makes the document in place of any answer it would not hand the middleware as a `200` of the entry's media type, and that it needs the origin's whole body, with `needs_whole_body`, so the origin is asked without the reader's conditional and range headers. By default a middleware works on every path its entry covers and says neither of the other two.
- A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is kept under the phase and the middleware's name for `create` in that phase, so a decision that needs a store or the network is made there while `create` stays synchronous. The fetch phase of a document that may be stored is prepared with no client IP, TLS details or client signals in the services. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. Core logs a refusal as a warning naming the middleware, and answers a status that is not a client or server error with `500`.
- A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A load is handed services with nothing of the reader in them, from `RuntimeServices::without_connection`, because its value serves every reader. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.<name>]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in memory over the host's value cache on Fastly or its key-value store on Cloudflare and Spin, with the memory copy alone on Axum, for a deployment that links it, because no stock build does. Each application state keeps a memory copy of its own, which lasts one request where an adapter builds its state for each request, as Cloudflare and Spin do. Every load runs in its caller.
- `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. A `module` no linked crate offers a cache from is refused by `ts config validate` and when the service starts.
Expand Down
43 changes: 20 additions & 23 deletions crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ use trusted_server_core::publisher::{
use trusted_server_core::request_signing::{
handle_trusted_server_discovery, handle_verify_signature,
};
use trusted_server_core::robots_txt::{ROBOTS_TXT_PATH, handle_robots_txt};
use trusted_server_core::settings::Settings;
use trusted_server_core::settings_data::{
default_config_key, default_config_store_name, get_settings_from_config_store_with,
Expand Down Expand Up @@ -484,7 +483,6 @@ enum NamedRouteHandler {
FirstPartyClick,
FirstPartySign,
FirstPartyProxyRebuild,
RobotsTxt,
}

struct NamedRoute {
Expand All @@ -493,7 +491,7 @@ struct NamedRoute {
handler: NamedRouteHandler,
}

fn named_routes() -> [NamedRoute; 15] {
fn named_routes() -> [NamedRoute; 14] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
Expand Down Expand Up @@ -578,23 +576,9 @@ fn named_routes() -> [NamedRoute; 15] {
primary_methods: &[Method::GET, Method::POST],
handler: NamedRouteHandler::FirstPartyProxyRebuild,
},
// Only when the settings carry `[robots-txt]`.
NamedRoute {
path: ROBOTS_TXT_PATH,
primary_methods: &[Method::GET, Method::HEAD],
handler: NamedRouteHandler::RobotsTxt,
},
]
}

/// Whether a named route is served for these settings.
fn named_route_is_served(settings: &Settings, handler: NamedRouteHandler) -> bool {
match handler {
NamedRouteHandler::RobotsTxt => settings.robots_txt.is_some(),
_ => true,
}
}

fn named_route_handler(
state: Arc<AppState>,
handler: NamedRouteHandler,
Expand Down Expand Up @@ -669,9 +653,6 @@ fn named_route_handler(
NamedRouteHandler::FirstPartyProxyRebuild => {
handle_first_party_proxy_rebuild(&state.settings, &services, req).await
}
NamedRouteHandler::RobotsTxt => {
handle_robots_txt(&state.settings, &services, &state.registry, req).await
}
}
},
))
Expand Down Expand Up @@ -810,6 +791,25 @@ impl TrustedServerApp {
let state = build_state_with_services(settings, Some(services))?;
Ok(build_router(&state))
}

/// Build the full router as
/// [`TrustedServerApp::routes_with_registrations`] does, with every
/// request handed a clone of `services`, as
/// [`TrustedServerApp::routes_with_settings_and_services`] hands them.
///
/// # Errors
///
/// Returns the errors [`TrustedServerApp::routes_with_registrations`]
/// returns.
pub fn routes_with_registrations_and_services(
settings: Settings,
integrations: &[IntegrationBuilder],
services: RuntimeServices,
) -> Result<RouterService, Report<TrustedServerError>> {
let state =
build_state_with_registrations_and_services(settings, integrations, Some(services))?;
Ok(build_router(&state))
}
}

fn build_router(state: &Arc<AppState>) -> RouterService {
Expand All @@ -834,9 +834,6 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
});

for route in named_routes() {
if !named_route_is_served(&state.settings, route.handler) {
continue;
}
for method in route.primary_methods {
router = router.route(
route.path,
Expand Down
136 changes: 136 additions & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,3 +918,139 @@ async fn the_sellers_module_makes_an_ads_txt_the_origin_does_not_have() {
"should be the sellers lines alone"
);
}

// ---------------------------------------------------------------------------
// robots.txt
// ---------------------------------------------------------------------------

/// The route test settings with a `[robots-txt]` section that refuses every
/// crawler and keeps one path open, and the entry that places the file.
fn settings_refusing_every_crawler() -> trusted_server_core::settings::Settings {
trusted_server_core::settings::Settings::from_toml(
r#"
[publisher]
domain = "test-publisher.example.com"
cookie_domain = ".test-publisher.example.com"
origin_url = "https://origin.test-publisher.example.com"
proxy_secret = "integration-test-proxy-secret"

[ec]
module = "hmac"

[ec.hmac]
passphrase = "test-secret-key-32-bytes-minimum"

[geo]
assume_single_jurisdiction = true

[robots-txt]
modules = "refuse_all"
always_allow = ["/ads.txt"]

[[fetch]]
media_type = "text/plain"
path = "/robots.txt"
middleware = ["robots_txt"]
"#,
)
.expect("should parse robots.txt test settings")
.with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample())
}

/// `/robots.txt` is the publisher path's, answered against an origin that
/// answers each request in turn. A `GET` has the origin's file replaced by
/// the refusal, a `HEAD` has the file made where the origin has none and
/// gets no body, and a `POST` keeps the origin's `404`, because a file is
/// made only for a `GET` or a `HEAD`. Each carries the refusal's
/// `X-Robots-Tag`, the file's own answer included.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn robots_txt_is_served_through_the_publisher_path_with_its_tag() {
use std::sync::Arc;

use edgezero_core::http::Response;
use trusted_server_core::platform::PlatformHttpClient;
use trusted_server_core::platform::test_support::{
StubHttpClient, build_services_with_http_client,
};

let origin = Arc::new(StubHttpClient::new());
origin.push_response_with_headers(
200,
b"User-agent: *\nAllow: /\n".to_vec(),
vec![("content-type", "text/plain")],
);
for _ in 0..2 {
origin.push_response_with_headers(
404,
b"<html><body>not found</body></html>".to_vec(),
vec![("content-type", "text/html")],
);
}
let router = TrustedServerApp::routes_with_settings_and_services(
settings_refusing_every_crawler(),
build_services_with_http_client(Arc::clone(&origin) as Arc<dyn PlatformHttpClient>),
)
.expect("should build router with a stub origin");
let robots_txt = |method: &str| {
edgezero_core::http::request_builder()
.method(method)
.uri("https://test-publisher.example.com/robots.txt")
.header("host", "test-publisher.example.com")
.body(edgezero_core::body::Body::empty())
.expect("should build request")
};
let tag = |response: &Response| {
response
.headers()
.get("x-robots-tag")
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
};
let body = |response: Response| {
let bytes = response
.into_body()
.into_bytes()
.expect("should hold the answer in memory");
String::from_utf8(bytes.to_vec()).expect("the body is text")
};

let get = router
.clone()
.oneshot(robots_txt("GET"))
.await
.expect("should serve the GET");
assert_eq!(get.status(), 200);
assert_eq!(tag(&get).as_deref(), Some("noindex, nofollow"));
assert_eq!(body(get), "User-agent: *\nAllow: /ads.txt\nDisallow: /\n");

let head = router
.clone()
.oneshot(robots_txt("HEAD"))
.await
.expect("should serve the HEAD");
assert_eq!(
head.status(),
200,
"the file is made where the origin has none"
);
assert_eq!(tag(&head).as_deref(), Some("noindex, nofollow"));
assert_eq!(body(head), "", "a HEAD gets no body");

let post = router
.clone()
.oneshot(robots_txt("POST"))
.await
.expect("should serve the POST");
assert_eq!(
post.status(),
404,
"a file is made only for a GET or a HEAD"
);
assert_eq!(tag(&post).as_deref(), Some("noindex, nofollow"));

assert_eq!(
origin.recorded_request_methods(),
["GET", "HEAD", "POST"],
"each request reaches the origin"
);
}
Loading
Loading