Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c281894
A vendor's modules share one table
jwrosewell Oct 11, 2026
ad011ff
The guides describe a vendor's shared table
jwrosewell Oct 11, 2026
f4291a2
Merge branch 'review/25-robots-txt-middleware' into review/26-shared-…
Jamesr51d Oct 12, 2026
1f320b3
A builder its shared table runs takes no module name
Jamesr51d Oct 12, 2026
e416ee0
Merge branch 'review/25-robots-txt-middleware' into review/26-shared-…
Jamesr51d Oct 12, 2026
0e1e9ad
Merge 25-robots-txt-middleware into 26-shared-tables
Jamesr51d Oct 12, 2026
7926a0e
Merge 25-robots-txt-middleware into 26-shared-tables
Jamesr51d Oct 12, 2026
1eacbf2
Merge 25-robots-txt-middleware into 26-shared-tables
jwrosewell Oct 13, 2026
1564f19
Merge 25-robots-txt-middleware into 26-shared-tables
jwrosewell Oct 13, 2026
d57a449
Merge split/25-robots-txt-middleware into split/26-shared-tables
Jamesr51d Oct 13, 2026
ea44f6d
Merge split/25-robots-txt-middleware into split/26-shared-tables
Jamesr51d Oct 13, 2026
5cbd69d
Merge 25-robots-txt-middleware into 26-shared-tables
jwrosewell Oct 13, 2026
9a272a3
A refused shared table names the setting and never its value
jwrosewell Oct 16, 2026
31b0fc5
A refused module table names the setting and never its value
jwrosewell Oct 16, 2026
a38f95e
Push checks that a shared table's secret settings name a key
jwrosewell Oct 16, 2026
2abfb32
The changelog and guides describe the two shared table fixes
jwrosewell Oct 16, 2026
5f0f63b
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
cdf9209
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
e595835
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
d33668f
The CMP id test expects the setting named and its value never quoted
jwrosewell Oct 16, 2026
dfda024
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
6faaf8c
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
9485c66
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
6a50c8a
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
b4afcce
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
b9dbdb3
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
9a195fc
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 16, 2026
d46470c
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 18, 2026
a1dedf8
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 18, 2026
040d47b
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 18, 2026
953a286
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 18, 2026
b46892a
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 19, 2026
9c77665
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 19, 2026
77bfd31
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 19, 2026
af0e3e8
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 19, 2026
0cc6497
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 28, 2026
17244b6
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 28, 2026
2225a3d
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 28, 2026
4d3f11c
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 28, 2026
2be39bd
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 28, 2026
a3234b4
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 28, 2026
c1b87f2
Merge split/25-robots-txt-middleware into split/26-shared-tables
jwrosewell Oct 28, 2026
180c491
Merge split/25-robots-txt-middleware into split/26-shared-tables
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `[shared.<vendor>]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table whose name is not lower case letters, digits, `_` or `-` is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared, resolved and checked at push to name a key as a module's own are. A shared table, or a module's own, that cannot be read or fails its validation is refused naming the setting at fault and never its value.
- `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`.
Expand Down
114 changes: 114 additions & 0 deletions crates/testing/seam-probe/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,120 @@ pub fn builder() -> IntegrationBuilder {
.with_adserver(&SEAM_PROBE_ADSERVER)
}

/// The vendor's name, which names the table `[shared.seam-probe]` that runs
/// [`shared_builder`].
pub const SEAM_PROBE_SHARED_VENDOR: &str = "seam-probe";

/// The id of the builder the shared table runs.
pub const SEAM_PROBE_SHARED_ID: &str = "seam_probe_shared";

/// The route the shared builder answers with the table's marker.
pub const SEAM_PROBE_SHARED_PATH: &str = "/integrations/seam_probe_shared/marker";

/// The shared route's suffix under the builder's proxy prefix.
const SHARED_ROUTE_SUFFIX: &str = "/marker";

/// What `[shared.seam-probe]` carries.
#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct SeamProbeShared {
/// The text the shared route answers with.
#[validate(length(min = 1))]
pub marker: String,
}

/// A route answering the shared table's marker, which proves the table ran
/// the builder and reached what it registered.
#[derive(Debug)]
struct SharedMarkerRoute {
marker: String,
}

impl SharedMarkerRoute {
async fn route(
&self,
_req: Request<EdgeBody>,
) -> Result<Response<EdgeBody>, Report<TrustedServerError>> {
Response::builder()
.status(200)
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
)
.body(EdgeBody::from(self.marker.clone().into_bytes()))
.map_err(|error| {
Report::new(TrustedServerError::Integration {
integration: SEAM_PROBE_SHARED_ID.to_string(),
message: error.to_string(),
})
})
}
}

#[async_trait(?Send)]
impl IntegrationProxy for SharedMarkerRoute {
fn integration_name(&self) -> &'static str {
SEAM_PROBE_SHARED_ID
}

fn routes(&self) -> Vec<IntegrationEndpoint> {
vec![self.get(SHARED_ROUTE_SUFFIX)]
}

async fn handle(
&self,
call: ModuleCall<'_>,
req: Request<EdgeBody>,
) -> Result<Response<EdgeBody>, Report<TrustedServerError>> {
call.inject_with(self, req, Self::route)?.await
}
}

/// Registers the shared route when `[shared.seam-probe]` is present.
///
/// # Errors
///
/// Returns an error when the table cannot be read or fails its validation.
pub fn register_shared(
settings: &Settings,
) -> Result<Option<IntegrationRegistration>, Report<TrustedServerError>> {
let Some(shared) = settings.shared_config::<SeamProbeShared>(SEAM_PROBE_SHARED_VENDOR)? else {
return Ok(None);
};
Ok(Some(
IntegrationRegistration::builder(SEAM_PROBE_SHARED_ID)
.without_js()
.with_proxy(Arc::new(SharedMarkerRoute {
marker: shared.marker,
}))
.build(),
))
}

/// Reports whether the shared table is present, validating it.
///
/// # Errors
///
/// Returns an error when the table cannot be read or fails its validation.
pub fn validate_shared(settings: &Settings) -> Result<bool, Report<TrustedServerError>> {
Ok(settings
.shared_config::<SeamProbeShared>(SEAM_PROBE_SHARED_VENDOR)?
.is_some())
}

/// The builder `[shared.seam-probe]` runs, which proves a vendor's shared
/// table from outside core.
#[must_use]
pub fn shared_builder() -> IntegrationBuilder {
IntegrationBuilder::new(
SEAM_PROBE_SHARED_ID,
SEAM_PROBE_SOURCE,
register_shared,
validate_shared,
)
.with_shared_settings(SEAM_PROBE_SHARED_VENDOR)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
42 changes: 42 additions & 0 deletions crates/trusted-server-adapter-axum/tests/seam_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,48 @@ async fn proxy_route_reports_the_modules_geo_and_that_the_preparer_ran() {
);
}

/// `[shared.seam-probe]` runs the builder the probe shares, whose route
/// answers the table's marker. Without the table the builder is not built,
/// and the table with no builder reading it is refused at startup.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_vendors_shared_table_runs_its_builder() {
let mut with = service_with(
settings_with(&format!(
"[shared.seam-probe]\nmarker = \"from the shared table\"\n{PROBE_BLOCK}"
)),
&[seam_probe::builder(), seam_probe::shared_builder()],
);
let response = get(&mut with, seam_probe::SEAM_PROBE_SHARED_PATH).await;
assert_eq!(
response.status().as_u16(),
200,
"the table runs the builder"
);
assert_eq!(body_text(response).await, "from the shared table");

let mut without = service_with(
settings_with(PROBE_BLOCK),
&[seam_probe::builder(), seam_probe::shared_builder()],
);
let response = get(&mut without, seam_probe::SEAM_PROBE_SHARED_PATH).await;
assert_ne!(
response.status().as_u16(),
200,
"without the table the builder is not built, so the route is not this server's"
);

let refused = TrustedServerApp::routes_with_registrations(
settings_with("[shared.seam-probe]\nmarker = \"x\"\n"),
&[seam_probe::builder()],
)
.err()
.expect("nothing reads the table");
assert!(
format!("{refused:?}").contains("[shared.seam-probe] is read by no module"),
"{refused:?}"
);
}

/// With `refuse_all` selected every response this adapter finalizes says not
/// to index, and the file itself is the publisher path's, made by the
/// `robots_txt` middleware the entry names, so no route of this server names
Expand Down
91 changes: 82 additions & 9 deletions crates/trusted-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,20 +509,36 @@ fn validate_secret_key_references(
)?;
}

// Each setting a selected module declares as naming a secret, where the
// module's table puts it to use.
// Each setting a running builder declares as naming a secret, where the
// builder's table puts it to use. A vendor's shared table runs the
// builders that carry its mark, and a section runs the module it selects.
for builder in crate::integrations::all_builders(extra_integrations) {
let secrets = builder.secret_settings();
let Some((section, written)) = builder
.module_name()
.filter(|_| !secrets.is_empty())
.and_then(|name| settings.module_selection(name))
else {
if secrets.is_empty() {
continue;
}
let (place, table) = if let Some(vendor) = builder.shared_settings() {
let Some(table) = settings.shared.table(vendor) else {
continue;
};
(
format!("{}.{vendor}", crate::settings::SHARED_TYPE),
table.clone(),
)
} else {
let Some((section, written)) = builder
.module_name()
.and_then(|name| settings.module_selection(name))
else {
continue;
};
(
format!("{section}.{written}"),
settings.section_table(section, written),
)
};
let table = settings.section_table(section, written);
for secret in secrets.iter().filter(|secret| (secret.in_use)(&table)) {
let path = format!("{section}.{written}.{}", secret.path.join("."));
let path = format!("{place}.{}", secret.path.join("."));
let reference = crate::module_secrets::value_at(&table, secret.path)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| missing_secret_key_reference(&path))?;
Expand Down Expand Up @@ -1736,6 +1752,63 @@ passphrase = "production-secret-key-32-bytes-min"
}
}

/// A builder its vendor's shared table runs, which declares a secret in
/// that table.
fn shared_builder_with_a_secret() -> IntegrationBuilder {
IntegrationBuilder::new(
"probe",
"example-crate",
crate::integrations::registry_test_support::probe_registration,
crate::integrations::registry_test_support::validate_nothing,
)
.with_shared_settings("probe")
.with_secret_settings(MODULE_SECRETS)
}

#[test]
fn deploy_validation_refuses_a_shared_table_s_secret_setting_in_use_that_names_no_key() {
for (table, expected) in [
(
Some(serde_json::json!({ "lock": true })),
Some("shared.probe.spare.key_name"),
),
(
Some(serde_json::json!({ "lock": true, "spare": { "key_name": " " } })),
Some("shared.probe.spare.key_name"),
),
(
Some(serde_json::json!({ "lock": true, "spare": { "key_name": "vendor_key" } })),
None,
),
// A setting the table does not put to use is not checked.
(Some(serde_json::json!({ "lock": false })), None),
// Without its table the builder does not run.
(None, None),
] {
let mut settings = valid_settings();
if let Some(table) = &table {
settings.shared = serde_json::from_value(serde_json::json!({ "probe": table }))
.expect("should read the shared table");
}

let result =
validate_settings_for_deploy_with(&settings, &[shared_builder_with_a_secret()]);

match expected {
Some(path) => {
let error = result.expect_err("should refuse a setting that names no key");
assert!(
format!("{error:?}").contains(path),
"should name the setting for {table:?}: {error:?}"
);
}
None => result.unwrap_or_else(|error| {
panic!("should accept {table:?}: {error:?}");
}),
}
}
}

const PLAN_RULE_MESSAGE: &str = "probe module refuses an enabled auction";

fn refuse_an_enabled_auction(
Expand Down
20 changes: 20 additions & 0 deletions crates/trusted-server-core/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ pub struct IntegrationBuilder {
module: Option<&'static str>,
section: Option<&'static str>,
loading_cache: Option<LoadingCacheFactory>,
shared: Option<&'static str>,
}

/// Gives the loading cache a module supplies, see
Expand Down Expand Up @@ -469,6 +470,7 @@ impl IntegrationBuilder {
module: None,
section: None,
loading_cache: None,
shared: None,
}
}

Expand Down Expand Up @@ -497,6 +499,7 @@ impl IntegrationBuilder {
module: None,
section: None,
loading_cache: None,
shared: None,
}
}

Expand Down Expand Up @@ -639,6 +642,23 @@ impl IntegrationBuilder {
self
}

/// Runs this builder when `[shared.<vendor>]` is present, for a vendor
/// whose modules all read that one table and cannot be built without it.
/// A builder the table runs is selected by no section, so the registry
/// refuses one that also has a module name.
#[must_use]
pub const fn with_shared_settings(mut self, vendor: &'static str) -> Self {
self.shared = Some(vendor);
self
}

/// The vendor whose `[shared.<vendor>]` table runs this builder, when one
/// does.
#[must_use]
pub const fn shared_settings(&self) -> Option<&'static str> {
self.shared
}

/// The loading cache this builder offers, when it offers one.
#[must_use]
pub const fn loading_cache(&self) -> Option<LoadingCacheFactory> {
Expand Down
Loading
Loading