Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
99b1acd
Add device and geo provider selection with the host-signal Edge Cooki…
jwrosewell Aug 19, 2026
20fee6a
Omit an unset provider selector from the serialized config
jwrosewell Aug 30, 2026
74047c1
Read the Cloudflare visitor region so US state opt-outs are honored
jwrosewell Aug 30, 2026
cf8d098
Reunite the request-info builders with their own documentation
jwrosewell Aug 31, 2026
de98934
Documentation and house-style cleanup for the device and geo provider PR
jwrosewell Aug 31, 2026
d3b4b06
Drop the unused HeaderMap import from the device tests
jwrosewell Sep 1, 2026
dcdb493
Give each vendor crate a visible maintainers declaration
jwrosewell Sep 2, 2026
8bb5987
Log the jurisdiction class rather than the value when EC creation is …
jwrosewell Sep 3, 2026
0832f00
Merge main into device and geo selection through split/1
jwrosewell Sep 14, 2026
a75571d
Resolve the host-signals passphrase from the secret store
jwrosewell Sep 14, 2026
37eadae
Merge the Edge Cookie review follow-ups from split/1
jwrosewell Sep 14, 2026
6e61e0b
Name the host-signal Edge Cookie provider host_signals
jwrosewell Sep 15, 2026
558bde5
Merge the per-provider [ec] tables from split/1
jwrosewell Sep 15, 2026
5e32cde
Merge main into device and geo provider selection
jwrosewell Sep 25, 2026
c6bb840
Merge the test state fix from split/1
jwrosewell Sep 25, 2026
80a842e
Merge split/1-ec-provider at 1e25353b8 into split/2-device-geo
jwrosewell Oct 6, 2026
2c34ed1
Stop refusing the host-signals spelling by name
jwrosewell Oct 6, 2026
badb58b
Resolve a cross-named built-in Edge Cookie block's passphrase once
jwrosewell Oct 6, 2026
13cfa9b
The device and geo seams say module where they said provider
jwrosewell Oct 6, 2026
6fc9cdf
Merge split/1-ec-provider at 6b2e303f1 into split/2-device-geo
jwrosewell Oct 7, 2026
35ccc36
Merge split/1-ec-provider at ef4c01347 into split/2-device-geo
jwrosewell Oct 7, 2026
cec6052
Comments say what the code does, with the history and plans taken out
jwrosewell Oct 7, 2026
09514b1
Merge split/1-ec-provider at 5d808f233 into split/2-device-geo
jwrosewell Oct 7, 2026
fb1d369
Merge split/1-ec-provider at 6f527710a into split/2-device-geo
jwrosewell Oct 7, 2026
47551a6
Merge split/1-ec-provider at d33507b32 into split/2-device-geo
jwrosewell Oct 8, 2026
138faf1
Merge split/1-ec-provider into split/2-device-geo
Jamesr51d Oct 13, 2026
968c459
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 15, 2026
4a04d83
Say the geo default uses the host platform's lookup
jwrosewell Oct 16, 2026
d3b6f39
Keep the host signal headers off every request core sends onward
jwrosewell Oct 16, 2026
2ee2c61
Merge split/2-device-geo at d3b6f39cd into split/2-device-geo
jwrosewell Oct 16, 2026
f36e540
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 16, 2026
b65e4d4
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 16, 2026
804af8d
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 16, 2026
2812972
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 18, 2026
bf0b49f
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 18, 2026
1186686
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 19, 2026
e637371
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 19, 2026
42449d9
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 19, 2026
1c32e65
Merge split/1-ec-provider into split/2-device-geo
jwrosewell Oct 28, 2026
d67a7ce
Merge split/1-ec-provider into split/2-device-geo
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
8 changes: 4 additions & 4 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin"
# native crate needs no change here. Axum (native), Cloudflare
# (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests
# (native) are simply not listed.
build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1"
check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1"
clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings"
test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1"
build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1"
check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1"
clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings"
test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1"
# Feature-on counterpart of `test-fastly`. `test-fastly` does NOT pass
# --all-features, so without this the reusable-sandbox code is linted by
# `clippy-fastly` but its tests never execute.
Expand Down
19 changes: 19 additions & 0 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
[workspace]
resolver = "2"
members = [
"crates/device/fastly",
"crates/geo/fastly",
"crates/trusted-server-adapter-axum",
"crates/trusted-server-adapter-cloudflare",
"crates/trusted-server-adapter-fastly",
Expand Down Expand Up @@ -120,6 +122,8 @@ toml_edit = "0.23.10"
tower = "0.4"
tracing = "0.1"
trusted-server-core = { path = "crates/trusted-server-core" }
trusted-server-device-fastly = { path = "crates/device/fastly" }
trusted-server-geo-fastly = { path = "crates/geo/fastly" }
trusted-server-js = { path = "crates/trusted-server-js" }
trusted-server-openrtb = { path = "crates/trusted-server-openrtb" }
url = "2.5.8"
Expand Down
10 changes: 10 additions & 0 deletions crates/device/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Device modules

Device-detection module crates live here, one per vendor. The Fastly module
(`trusted-server-device-fastly`) classifies a request with the host's TLS and
HTTP/2 signals. Future vendor modules (for example
`crates/device/<vendor>`) slot in alongside it.

The built-in default module (User-Agent only) ships in `trusted-server-core`
(`ec::device`). Adapters select and inject the vendor module via
`build_device_module`.
25 changes: 25 additions & 0 deletions crates/device/fastly/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "trusted-server-device-fastly"
description = "Fastly host device module exposing opt-in TLS and HTTP/2 signals."
authors = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
publish = { workspace = true }
version = { workspace = true }

[lib]
doctest = false

[lints]
workspace = true

[dependencies]
trusted-server-core = { workspace = true }
fastly = { workspace = true }

# The visible owner of this vendor crate, the way Prebid.js requires a named
# maintainer of every adapter. Fastly has not yet adopted this crate, so the
# Trusted Server maintainers own it until a vendor owner steps up.
[package.metadata.maintainers]
owner = "Trusted Server maintainers"
status = "seeking vendor owner"
98 changes: 98 additions & 0 deletions crates/device/fastly/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! The Fastly device module and host-signal capture.
//!
//! [`FastlyDeviceModule`] strengthens the built-in User-Agent classification
//! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly
//! Compute. It is selected by `[device] module = "fastly"` and wired in by the
//! Fastly adapter, which injects the request info and the captured host signals.
//!
//! [`FastlyHostSignals`] captures those signals from a live Fastly request
//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can
//! be shared as an injected [`HostSignals`] service that outlives the borrow of
//! the request. Capturing through the SDK is why this crate depends on the
//! `fastly` crate and builds only for the `wasm32-wasip1` target; off-host the
//! accessors return `None`, so classification degrades to User-Agent only. The
//! platform-neutral [`HostSignals`], [`RequestInfo`], and [`DeviceModule`]
//! traits and the built-in default live in `trusted-server-core`, where the
//! `DeviceSignals` classification logic stays unit-tested.

use std::sync::Arc;

use fastly::Request as FastlyRequest;
use trusted_server_core::ec::device::{DeviceModule, DeviceSignals};
use trusted_server_core::evidence::{HostSignals, RequestInfo};

/// Host-computed client signals captured from a live Fastly request.
///
/// Reads the TLS JA4 and HTTP/2 signals once through the Fastly SDK and
/// owns them, so the value can be injected as a [`HostSignals`] service that
/// outlives the borrow of the request it was captured from. Off-host the SDK
/// accessors return `None`, so the signals are simply absent.
#[derive(Debug, Clone, Default)]
pub struct FastlyHostSignals {
ja4: Option<String>,
h2: Option<String>,
}

impl FastlyHostSignals {
/// Builds host signals from already-captured signal values.
///
/// Use this when the adapter has read the signals once (for example
/// into the client metadata, or from the trusted internal headers the entry
/// point injects) and wants to share them without another SDK call.
#[must_use]
pub fn new(ja4: Option<String>, h2: Option<String>) -> Self {
Self { ja4, h2 }
}

/// Captures the TLS JA4 and HTTP/2 signals from a live Fastly request.
#[must_use]
pub fn from_request(req: &FastlyRequest) -> Self {
Self {
ja4: req.get_tls_ja4().map(str::to_string),
h2: req.get_client_h2_fingerprint().map(str::to_string),
}
}
}

impl HostSignals for FastlyHostSignals {
fn ja4(&self) -> Option<&str> {
self.ja4.as_deref()
}

fn h2(&self) -> Option<&str> {
self.h2.as_deref()
}
}

/// The Fastly device module, opt-in via `[device] module = "fastly"`.
///
/// Classifies a request with [`DeviceSignals::derive`], which strengthens the
/// User-Agent classification with the host signals. It reads the User-Agent
/// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its
/// injected [`HostSignals`], so the browser/bot gate is backed by the live
/// request.
pub struct FastlyDeviceModule {
host_signals: Arc<dyn HostSignals>,
}

impl FastlyDeviceModule {
/// Creates the module with its injected host signals.
#[must_use]
pub fn new(host_signals: Arc<dyn HostSignals>) -> Self {
Self { host_signals }
}
}

impl DeviceModule for FastlyDeviceModule {
fn id(&self) -> &'static str {
"fastly"
}

fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals {
DeviceSignals::derive(
request_info.user_agent(),
self.host_signals.ja4(),
self.host_signals.h2(),
)
}
}
13 changes: 13 additions & 0 deletions crates/fastly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Minimal Viceroy config for testing crates nested one level deeper than the
# adapters (for example `crates/device/fastly` and `crates/geo/fastly`).
#
# The shared wasm test runner in `.cargo/config.toml` starts Viceroy with
# `-C ../../fastly.toml`, resolved from the crate directory. For a two-level
# crate such as `crates/trusted-server-adapter-fastly` that reaches the
# repository root manifest. For a three-level crate it resolves here, to
# `crates/fastly.toml`. These crates' unit tests use no backends, KV stores,
# or dictionaries, only a manifest Viceroy can start from.
manifest_version = 3
name = "trusted-server-nested-crate-tests"

[local_server]
21 changes: 21 additions & 0 deletions crates/geo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Geo modules

Geo and IP-intelligence module crates live here, one per implementation, each
implementing the `PlatformGeo` trait from `trusted-server-core`:

- `crates/geo/fastly` (`trusted-server-geo-fastly`) is the host platform geo
module for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter
injects it via `build_geo_module`. It depends on the Fastly SDK, so it builds
only for `wasm32-wasip1`.
- Vendor geo modules (for example `crates/geo/<vendor>`) will live alongside
it, one per vendor, selected by the `[geo] module` setting.

Whatever the source, a module returns the same `GeoInfo` coding. The country
is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision
code with no country prefix (`CA`), so the Fastly and other modules feed the
same downstream rules without translation.

The platform-neutral `PlatformGeo` trait and `DisabledGeo` both live in
`trusted-server-core`. With no `[geo] module`, or `module = "platform"`, the
adapter uses its host platform's own lookup. `module = "none"` selects
`DisabledGeo`, which resolves no location and makes no host call.
26 changes: 26 additions & 0 deletions crates/geo/fastly/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "trusted-server-geo-fastly"
description = "Fastly host geo module backed by the Fastly geolocation API."
authors = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
publish = { workspace = true }
version = { workspace = true }

[lib]
doctest = false

[lints]
workspace = true

[dependencies]
trusted-server-core = { workspace = true }
error-stack = { workspace = true }
fastly = { workspace = true }

# The visible owner of this vendor crate, the way Prebid.js requires a named
# maintainer of every adapter. Fastly has not yet adopted this crate, so the
# Trusted Server maintainers own it until a vendor owner steps up.
[package.metadata.maintainers]
owner = "Trusted Server maintainers"
status = "seeking vendor owner"
47 changes: 47 additions & 0 deletions crates/geo/fastly/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! The Fastly host geo module.
//!
//! [`FastlyPlatformGeo`] implements [`PlatformGeo`] using Fastly's `geo_lookup`,
//! for deployments on Fastly Compute. It is the host platform's geo module,
//! injected by the Fastly adapter via `build_geo_module`. With no selector,
//! or `module = "platform"`, this host lookup resolves the location, and
//! `module = "none"` disables geo instead.
//!
//! Like the Fastly device module, this crate calls the Fastly SDK directly,
//! so it depends on the `fastly` crate and builds only for the `wasm32-wasip1`
//! target. The platform-neutral `PlatformGeo` trait and the `DisabledGeo`
//! default both live in `trusted-server-core`.

use std::net::IpAddr;

use error_stack::Report;
use fastly::geo::{Geo, geo_lookup};
use trusted_server_core::platform::{GeoInfo, PlatformError, PlatformGeo};

/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`].
fn geo_from_fastly(geo: &Geo) -> GeoInfo {
GeoInfo {
city: geo.city().to_string(),
country: geo.country_code().to_string(),
continent: format!("{:?}", geo.continent()),
latitude: geo.latitude(),
longitude: geo.longitude(),
metro_code: geo.metro_code(),
region: geo.region().map(str::to_string),
asn: None,
}
}

/// Fastly geo-lookup implementation of [`PlatformGeo`].
///
/// The host platform geo module for Fastly Compute. The adapter injects it via
/// `build_geo_module`. With no selector, or `module = "platform"`, it
/// resolves the location, and `module = "none"` disables geo instead.
pub struct FastlyPlatformGeo;

impl PlatformGeo for FastlyPlatformGeo {
fn lookup(&self, client_ip: Option<IpAddr>) -> Result<Option<GeoInfo>, Report<PlatformError>> {
Ok(client_ip
.and_then(geo_lookup)
.map(|geo| geo_from_fastly(&geo)))
}
}
10 changes: 5 additions & 5 deletions crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,11 @@ fn build_state_with_services(
// whose application state is built once at start-up while theirs is rebuilt
// for every request. With no services supplied it threads no module, so
// `EcContext` resolves the selection itself on every request, building a
// fresh built-in module that reads no request data; this dev server
// accepts that per-request construction rather than caching a resolved
// module.
// fresh built-in module that reads no request data. It supplies no host
// signals either, so the `host_signals` argument is `None`.
ensure_module_available(
&settings.ec,
None,
services
.as_ref()
.and_then(RuntimeServices::resolved_ec_module),
Expand All @@ -128,7 +128,7 @@ impl AppState {
fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices {
self.services
.clone()
.unwrap_or_else(|| build_runtime_services(ctx))
.unwrap_or_else(|| build_runtime_services(ctx, &self.settings))
}
}

Expand Down Expand Up @@ -812,7 +812,7 @@ mod tests {
.body(edgezero_core::body::Body::empty())
.expect("should build test request");
let ctx = RequestContext::new(req, PathParams::default());
let services = build_runtime_services(&ctx);
let services = build_runtime_services(&ctx, &state.settings);
let req = ctx.into_request();

let error = build_ec_context(&state, &services, &req)
Expand Down
14 changes: 10 additions & 4 deletions crates/trusted-server-adapter-axum/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,10 @@ impl PlatformHttpClient for AxumPlatformHttpClient {
/// The generic runtime KV slot uses
/// [`trusted_server_core::platform::UnavailableKvStore`]. A `warn` log is
/// emitted once per process.
pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices {
pub fn build_runtime_services(
ctx: &edgezero_core::context::RequestContext,
settings: &trusted_server_core::settings::Settings,
) -> RuntimeServices {
static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
KV_WARNED.get_or_init(|| {
log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore).");
Expand Down Expand Up @@ -569,9 +572,12 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R
// API-route integration flow by reusing a poisoned connection after a
// truncated POST. Revisit pooling if profiling shows allocation cost.
.http_client(Arc::new(AxumPlatformHttpClient::new()))
.geo(Arc::clone(GEO.get_or_init(|| {
Arc::new(AxumPlatformGeo) as Arc<dyn PlatformGeo>
})))
// Route through the [geo] module selector like the Fastly adapter,
// so the selector behaves the same on every adapter.
.geo(trusted_server_core::platform::build_geo_module(
settings,
Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc<dyn PlatformGeo>)),
))
.client_info(ClientInfo {
client_ip,
tls_protocol: None,
Expand Down
Loading
Loading