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
13d432c
Add a pluggable Edge Cookie provider seam with the built-in HMAC prov…
jwrosewell Aug 18, 2026
ea51812
Accept the provider-code envelope on the partner-facing identifier paths
jwrosewell Aug 27, 2026
1f4a70b
Rename the legacy passphrase migration so CodeQL stops tainting Settings
jwrosewell Aug 27, 2026
f0ca12a
Stop serving without identity when a selected provider is unavailable
jwrosewell Aug 28, 2026
b2bb944
Hold the deprecated EC passphrase to the same rules as the new block
jwrosewell Aug 28, 2026
472218b
Reject unknown keys in the built-in HMAC provider block
jwrosewell Aug 28, 2026
c4d2f1d
Stop rather than run stateless when the hmac block is missing
jwrosewell Aug 28, 2026
93cd1e8
Restore the missing line continuation in the mint rejection message
jwrosewell Aug 28, 2026
a265c96
Give EdgeCookieProvider its own doc comment back
jwrosewell Aug 28, 2026
004581c
Delete the unused ec::get_ec_id helper
jwrosewell Aug 28, 2026
d6041f0
Correct the provider module docs about when evidence arrives
jwrosewell Aug 28, 2026
885e3ce
Replace the scattered EC provider key strings with a typed selector
jwrosewell Aug 28, 2026
69649aa
Reserve core's own response surface against provider effects
jwrosewell Aug 29, 2026
53d632e
Dispatch partner-path identifier checks by provider code
jwrosewell Aug 29, 2026
941297f
Let each provider decide whether it needs the client IP
jwrosewell Aug 29, 2026
e317190
State a real retirement condition for the legacy bare-identifier reader
jwrosewell Aug 29, 2026
343ac3e
Key identity-graph reads and write-backs by the canonical form
jwrosewell Aug 29, 2026
8684c69
Egress only an Edge Cookie identifier the provider recognizes
jwrosewell Aug 29, 2026
20bb082
Record the cluster-count gap the identifier envelope opens
jwrosewell Aug 29, 2026
84925ca
Consume the refused Report in the testlight egress tests
jwrosewell Aug 29, 2026
e45990b
Drop the request-evidence accessors that have no caller
jwrosewell Aug 29, 2026
b146aeb
Collapse the EC provider selector to statelessness and a named provider
jwrosewell Aug 29, 2026
7c0428d
Accumulate provider response headers instead of replacing the origin's
jwrosewell Aug 29, 2026
69d5ed0
State what a provider switch really does to existing identities
jwrosewell Aug 29, 2026
60a1f4b
Name the design documents rather than their paths in doc comments
jwrosewell Aug 30, 2026
ada4d79
Restore the line continuations missed in the neighbouring files
jwrosewell Aug 30, 2026
4cf202f
Correct the two provider doc comments the earlier pass missed
jwrosewell Aug 30, 2026
abdd4cb
Stop a provider code from panicking when a vendor builds one at run time
jwrosewell Aug 30, 2026
138dc3a
Refuse two Edge Cookie providers claiming the same name
jwrosewell Aug 30, 2026
9f4061a
Build the internal header list from the Edge Cookie response headers
jwrosewell Aug 30, 2026
0f0722f
Resolve the Edge Cookie provider once per request instead of twice
jwrosewell Aug 30, 2026
a698f5b
Load Spin settings from the config store instead of a baked template
jwrosewell Aug 30, 2026
252aade
Stop exposing an inbound Edge Cookie identifier nothing has vouched for
jwrosewell Aug 30, 2026
6cc3c91
Keep the whole request-evidence interface
jwrosewell Aug 30, 2026
5d87e24
Documentation and house-style cleanup for the Edge Cookie provider PR
jwrosewell Aug 31, 2026
11cc575
Name the caching reason accurately in the reserved-header rejection
jwrosewell Aug 31, 2026
9c50e04
Add device and geo provider selection with the host-signal Edge Cooki…
jwrosewell Aug 19, 2026
b1fba32
Omit an unset provider selector from the serialized config
jwrosewell Aug 30, 2026
8a275df
Read the Cloudflare visitor region so US state opt-outs are honored
jwrosewell Aug 30, 2026
2e37581
Reunite the request-info builders with their own documentation
jwrosewell Aug 31, 2026
1371127
Documentation and house-style cleanup for the device and geo provider PR
jwrosewell Aug 31, 2026
2edfba1
Add the permission model with the Privacy Taxonomy vocabulary
jwrosewell Aug 19, 2026
d0bbc6d
Correct the permission-model docs to what the code does
jwrosewell Aug 27, 2026
fb73ace
Give the remaining test fixtures the now-required default country
jwrosewell Aug 29, 2026
6f95f82
Say which geo lookups can reach the requires-signal floor, and prove it
jwrosewell Aug 29, 2026
3a671cc
Scope the permission-gating claim to the provider it is true of
jwrosewell Aug 30, 2026
e335575
State that evidence is not rationed and use is
jwrosewell Aug 30, 2026
f8dba74
Name the IAB Tech Lab Privacy Taxonomy as the source of the vocabulary
jwrosewell Aug 30, 2026
19d2f94
Build the test provider code through the macro that cannot fail
jwrosewell Aug 30, 2026
5fc3382
Documentation and house-style cleanup for the permission model PR
jwrosewell Aug 31, 2026
35f6ef2
Complete the mint and fingerprint house-style sweep after rebase
jwrosewell Aug 31, 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"

# --- Axum adapter (native dev server) ---
build-axum = "build -p trusted-server-adapter-axum"
Expand Down
95 changes: 93 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ crates/
trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary)
trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component)
trusted-server-cli/ # Host-target `ts` operator CLI
device/
fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider)
edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core)
geo/ # vendor geo provider crates (host geo is injected by the adapter)
trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles
lib/ # TS source, Vitest tests, esbuild pipeline
```
Expand Down Expand Up @@ -58,7 +62,9 @@ fastly compute serve
# Deploy to Fastly
fastly compute publish

# Run Axum dev server (native — no Viceroy)
# Run Axum dev server (native — no Viceroy). Settings load at runtime from the
# platform config store on every adapter; publish an operator config with
# `ts config push` (see trusted-server.example.toml for the template).
cargo run -p trusted-server-adapter-axum

# Test Axum adapter only
Expand Down Expand Up @@ -142,6 +148,20 @@ cd crates/trusted-server-js/lib && node build-all.mjs
cargo install viceroy --version 0.17.0 --locked --force
```

### Windows (use WSL for the Linux-only tests)

The Rust adapter tests run natively on Windows through the cargo aliases
(`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`),
and CI runs these on both `ubuntu-latest` and `windows-latest`.

The Docker-based integration suite (`scripts/integration-tests.sh`) and the
Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`,
which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them
inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the
WSL distro with the same toolchain as `.tool-versions` (rustup + the
`wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then
run the scripts from a clone on the WSL native filesystem for fast builds.

---

## Coding Conventions
Expand Down Expand Up @@ -269,12 +289,48 @@ impl core::error::Error for MyError {}

## Other guidelines

- Use US English spelling everywhere: code, identifiers, comments,
documentation, tests, commit messages, and configuration. For example, write
`color`, `behavior`, and `optimize`, not `colour`, `behaviour`, or `optimise`.
Where a term comes from an external source (for example the IAB TCF purpose
names), match that source's spelling even when it is not US English.
- Use only example or fictional information in comments, tests, docs, examples,
and similar non-runtime materials. (eg. for urls use: example.com domains only)
- Do not write or commit real domains, customer names, credentials,
configuration values, or other potentially sensitive real-world information in
comments, tests, docs, or examples.

### Permission model terminology

Permissions are the primitive. A provider declares the permissions it requires
(`required_permissions`) and the system decides whether each is _set_. Consent
is only one of many ways a permission may be established. Country or
jurisdiction rules (a `Granted` group baseline), legitimate interest, or
configuration can set a permission with no consent at all.

- A provider that needs nothing **requires no permission**. Never write that it
"runs without any consent".
- A gated provider **runs once its required permissions are set**, by whatever
method.

**Evidence is not rationed, use is.** Every provider and every integration sees
all the evidence available for a request, including host signals such as the TLS
JA4 and HTTP/2 signals. The core never decides which vendor may see what,
because withholding a signal from one vendor and not another discriminates
between them, and the core stays neutral. What a vendor may *do* with the
evidence is governed by the permissions it declares and the system sets. Access
is universal, use is gated.

The practical consequence: never "fix" a vendor's access to a signal by hiding
the signal. If a use needs controlling, express it as a permission. A change
that removes evidence from a provider's reach is working against the
architecture, not protecting it.

- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`,
GDPR and TCF strings) where it genuinely means a consent signal. In the
permission layer prefer "permission", "set" / "unset", and "signal" (consent
is one kind of signal, alongside privacy and opt-out signals).

---

## Git Commit Conventions
Expand All @@ -291,6 +347,41 @@ Bad: `"fix: added feature flags"`

---

## Provider Architecture

Each vendor-differentiated capability is pluggable behind its own trait, so a
deployment selects an implementation and the core stays neutral:

| Capability | Trait | Selector | Built-in (core) | Vendor / host crates |
| --------------------- | ---------------------------------------- | ------------------- | --------------------------------------- | ---------------------------- |
| Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/<vendor>` |
| Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/<vendor>` |
| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/<vendor>` |

Principles for adding or changing a provider:

- **Core stays neutral.** The trait and the host-neutral default live in
`trusted-server-core`. Host-specific and vendor implementations live in their
own crates and are injected by the adapter (for example `build_device_provider`
and `build_geo_provider`), so core never depends on a host SDK or a vendor, and
the default request path makes no host-specific calls.
- **Providers read request evidence, not a fixed parameter set.** A provider must
be able to see everything about the request it needs (User-Agent, headers, and
host signals such as the TLS JA4 and HTTP/2 signals) through an evidence
abstraction rather than a hard-coded struct of fields. Host signals come from
the host (the Fastly SDK) and are opt-in, so a neutral provider triggers no
host signal calls.
- **Providers are separated by capability but composed per request, and one may
need another's output.** Geo resolves the country and region the permission
model uses, and the permission model gates whether the Edge Cookie provider
runs. Device signals gate Edge Cookie writes (the browser / bot gate). When
multiple vendor providers share a backend (for example a vendor's Edge Cookie,
geo, and device provider on one cloud pipeline) they share a single call per
request rather than calling independently. Give a provider the inputs and
upstream results it needs explicitly, rather than having it reach into globals.

---

## Integration System

Integrations register in Rust via:
Expand Down Expand Up @@ -324,7 +415,7 @@ IntegrationRegistration::builder(ID)
| --------------------- | ---------------------------------------------------------- |
| `edgezero.toml` | EdgeZero app/platform manifest and logical stores |
| `fastly.toml` | Fastly service configuration and build settings |
| `trusted-server.example.toml` | Source-controlled Trusted Server app-config template |
| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and `[geo] default_country`) |
| `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope |
| `rust-toolchain.toml` | Pins Rust version to 1.95.0 |
| `.env.dev` | Local development environment variables |
Expand Down
39 changes: 39 additions & 0 deletions Cargo.lock

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

5 changes: 5 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 @@ -95,6 +97,7 @@ rustls-pemfile = "2"
scraper = "0.24.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.149"
serde_yaml_ng = "0.10"
sha2 = "0.10.9"
simple_logger = "5"
spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] }
Expand All @@ -109,6 +112,8 @@ toml = "1.1"
toml_edit = "0.23.10"
tower = "0.4"
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 providers

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

The built-in default provider (User-Agent only) ships in `trusted-server-core`
(`ec::device`). Adapters select and inject the vendor provider via
`build_device_provider`.
18 changes: 18 additions & 0 deletions crates/device/fastly/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "trusted-server-device-fastly"
description = "Fastly host device provider 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 }
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 provider and host-signal capture.
//!
//! [`FastlyDeviceProvider`] 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] provider = "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 [`DeviceProvider`]
//! 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::{DeviceProvider, 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 provider, opt-in via `[device] provider = "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 FastlyDeviceProvider {
host_signals: Arc<dyn HostSignals>,
}

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

impl DeviceProvider for FastlyDeviceProvider {
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(),
)
}
}
10 changes: 10 additions & 0 deletions crates/edgecookie/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Edge Cookie providers

Vendor Edge Cookie provider crates live here, one per vendor, for example
`crates/edgecookie/<vendor>`. Each implements the `EdgeCookieProvider` trait
from `trusted-server-core` and is wired in by an adapter.

The built-in HMAC provider (HMAC over the client IP) ships in
`trusted-server-core` (`ec::provider`), so no crate is needed for it. There is
no default provider; a deployment selects one explicitly with `[ec] provider`.
This directory is a placeholder until a vendor provider is added.
Loading
Loading