Skip to content

Commit 265a028

Browse files
committed
Add pluggable Edge Cookie, device, and geo providers gated by a permission model
Replace three hard-wired request-time decisions with configuration-selected providers behind small traits: Edge Cookie identity (EdgeCookieProvider), the device classification the bot gate uses (DeviceProvider), and geolocation (PlatformGeo). Gate a provider's execution on a technical permission model that separates legal policy from the core, and remove the country-based allows_ec_creation branching. Permissions are the single currency every service and provider reads, and none reads consent directly. Consent is one of several sources (the country and region baseline, a consent signal, a configuration decision, or data from elsewhere) that set a request's permissions. Feature and provider code works against a clean, stable set that does not change as laws or consent frameworks change; a source needing a new distinction adds a permission to the model rather than leaking a branch into consumers. The country and region rules live in a human-editable permissions.yaml embedded into the build (include_str! in permissions.rs), so a policy owner changes them in version control rather than in code. It defines named groups that each spell out every permission flag (gdpr-eu, gdpr-uk, us-opt-out) and rules mapping a country or country/state to a group, with optional +permission (granted) or -permission (denied) overrides. Rule keys use the ISO 3166-1 alpha-2 country and ISO 3166-2 subdivision codes a geo provider returns, matched case-insensitively, so any geo provider feeds the same rules without translation. PermissionMaps::standard() parses the file once into a static cache and hands out a shared reference (no per-request clone). A request that matches no rule, or whose country the geo provider cannot resolve, uses the deployer's [geo] default_country. Trusted Server does not assume a jurisdiction, so that default is required and validated at startup: startup fails when it is unset or does not resolve to a known rule. The shipped example uses the most protective baseline (FR, GDPR-EU, where every permission requires a signal); a deployer sets the jurisdiction that governs its traffic. Because policy is a declarative map rather than branching code, a third party can read permissions.yaml and see exactly what each permission resolves to for any country or region. Control stays in the core. A provider advertises the permissions its data use requires (required_permissions()), and the core, not the provider, decides whether it runs: it resolves the request's PermissionState once (in EcContext) and refuses to execute a provider whose required permissions are not set, so generate_if_needed returns before the provider is even built. Route every Trusted Server data decision through the resolved permissions, not Edge Cookie creation alone. Bidstream EID transmission gates on the resolved store-on-device and select-personalised-ads permissions (gate_eids_by_permissions) instead of re-inspecting TCF and a raw gdpr_applies flag. A request whose permissions do not allow the Edge Cookie always has its EC response headers stripped, but the destructive step (expire the browser cookie, delete the consent KV entry, write the identity-graph tombstone) fires only on an explicit withdrawal signal (ec_storage_withdrawn: a TCF record refusing storage, or a US-style opt-out), never on a merely not-permitted state. This holds on every path: ec_finalize_response, the publisher proxy (apply_ec_headers), and both adapter bot gates. A pre-consent or fail-closed request therefore suppresses the Edge Cookie for that response but does not destroy an already-issued identifier before the user has had the chance to consent. The jurisdiction-gated has_explicit_ec_withdrawal and ec_consent_withdrawn are gone. Map consent to a per-permission signal in one place (permission_signal). A TCF record is authoritative wherever present, so the EU defers to the CMP; a US-style opt-out (GPC, GPP sale opt-out, US Privacy opt-out) revokes a granted baseline, and the map decides where a revoke has an effect. Precedence is explicit: a present TCF record wins over a US-style opt-out. Downstream RTB partners (Prebid, APS) still receive the full regulatory context forwarded verbatim and run their own enforcement; the permission model governs only Trusted Server's own actions. Wire providers by dependency injection. A provider reads request evidence through the RequestInfo and HostSignals traits (evidence.rs) rather than a fixed field struct, borrowed at call time so no per-request HeaderMap is cloned. RequestInfo carries request data any host can supply (client IP, User-Agent, headers); HostSignals carries the host-computed TLS JA4 and HTTP/2 fingerprints and is opt-in, so a neutral provider triggers no host fingerprint call. The adapter is the composition root: it captures host signals once at the entry point and supplies them, and a provider that needs a service the host cannot supply cannot be built, so the request stops rather than minting a degraded identifier. Core defines the traits and the neutral built-in defaults and never calls a host SDK. Support two Edge Cookie provider shapes behind the one trait. A server-side provider mints at the edge: the built-in HMAC provider (over the client IP), or a host-signal provider that derives the identifier from the host fingerprints and client IP on any host that supplies a HostSignals. A client-side provider defers on the page request, lets the browser do the work, and posts the result to a new POST /_ts/api/v1/ec/resolve endpoint that mints the cookie from the verified value on its own response; verifying the posted value is the provider's responsibility, so a client cannot forge an Edge Cookie. A neutral client-fixed demo provider (client and server share one fixed known word the server verifies) exercises the path end to end with a page script shipped in the tsjs bundle when it is selected, and is for testing only. Move the opt-in Fastly device and host geo providers into their own crates (crates/device/fastly, crates/geo/fastly), selected and injected by the adapter, so core keeps only the DeviceProvider and PlatformGeo traits and the neutral defaults. The Fastly device provider reads the User-Agent from the borrowed RequestInfo and the fingerprints from a FastlyHostSignals built from the live request, and the adapter shares that FastlyHostSignals as the host-signal service so the host-signal Edge Cookie provider can use it too. A placeholder crates/edgecookie directory marks where vendor Edge Cookie provider crates will live. Restructure the [ec] configuration section (breaking change). The flat [ec] passphrase field is removed and [ec] rejects unknown fields, so a config carrying it fails to parse; the HMAC key moves to [ec.providers.hmac] passphrase. Edge Cookie identity is off by default: EC runs only when [ec] provider names a configured provider, so a config that adds [ec.providers.hmac] but omits provider = "hmac" parses cleanly and runs statelessly. Migration for an existing deployment: [ec] provider = "hmac" [ec.providers.hmac] passphrase = "your-existing-passphrase" The provider selectors ([ec]/[device]/[geo] provider) and the mandatory [geo] default_country are validated inside Settings::finalize_deserialized, the single point every deserialization path shares (TOML file, the runtime config-store blob, and the ts CLI config push), so no load path can bypass the checks. trusted-server.example.toml is the checked-in template and shows the new sections. The default jurisdiction baseline is logged once per settings load so an operator can see which permissions the unmatched-request default grants without a signal. Add an IntegrationResponseMutator hook for outbound response headers. No built-in integration registers one yet; the registry carries the hook so an integration crate (for example bot protection emitting Accept-CH) can register a mutator without a core change. Standardize spelling on US English, recorded as the convention in CLAUDE.md, keeping external-source terms such as the IAB TCF purpose names as their source spells them. Add the provider-architecture and permission-model sections to CLAUDE.md, and document the model, the provider selectors, and the permission sources in the guides. Cover Windows in CI. The Rust adapter test jobs run on both ubuntu-latest and windows-latest. The Docker-based integration suite and the Cloudflare worker build remain Linux tools, run through WSL on Windows as CLAUDE.md documents. The integration app-config fixture selects the HMAC provider and sets [geo] default_country = US/CA, so a request with no resolvable geolocation under Viceroy maps to a US opt-out baseline and the GPC-withdrawal scenario exercises permission-driven cookie expiry.
1 parent b260260 commit 265a028

69 files changed

Lines changed: 5703 additions & 996 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cargo/config.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@ build_cli_macos = ["build", "--package", "trusted-server-cli", "--target", "aarc
1616
test_cli_linux = ["test", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"]
1717
build_cli_linux = ["build", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"]
1818
# Fastly adapter + shared crates (wasm32-wasip1 via Viceroy)
19-
# Excludes Axum (native-only), Cloudflare (wasm32-unknown-unknown), and Spin (separate wasm32-wasip1 job)
20-
test-fastly = ["test", "--workspace", "--exclude", "trusted-server-adapter-axum", "--exclude", "trusted-server-adapter-cloudflare", "--exclude", "trusted-server-adapter-spin", "--target", "wasm32-wasip1"]
19+
# Excludes Axum (native-only), Cloudflare (wasm32-unknown-unknown), and Spin
20+
# (each a separate job/target). Also excludes the provider crates under
21+
# crates/<type>/<vendor> (trusted-server-device-fastly, trusted-server-geo-fastly):
22+
# their depth puts them outside the Viceroy runner's `-C ../../fastly.toml` path.
23+
# Both depend on the Fastly SDK, so they are wasm-only, compiled for wasm by
24+
# clippy/check/build with no host-runtime tests to run. Their pure classification
25+
# logic is unit-tested in trusted-server-core.
26+
test-fastly = ["test", "--workspace", "--exclude", "trusted-server-adapter-axum", "--exclude", "trusted-server-adapter-cloudflare", "--exclude", "trusted-server-adapter-spin", "--exclude", "trusted-server-device-fastly", "--exclude", "trusted-server-geo-fastly", "--target", "wasm32-wasip1"]
2127
# Axum dev server adapter (native)
2228
test-axum = ["test", "-p", "trusted-server-adapter-axum"]
2329
# Cloudflare adapter (native host; WASM target checked separately in CI)

.github/workflows/test.yml

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ on:
1111

1212
jobs:
1313
test-rust:
14-
name: cargo test
15-
runs-on: ubuntu-latest
14+
name: cargo test (${{ matrix.os }})
15+
runs-on: ${{ matrix.os }}
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
os: [ubuntu-latest, windows-latest]
1620
steps:
1721
- uses: actions/checkout@v4
1822

@@ -50,8 +54,12 @@ jobs:
5054
run: cargo test-fastly
5155

5256
test-axum:
53-
name: cargo test (axum native)
54-
runs-on: ubuntu-latest
57+
name: cargo test (axum native, ${{ matrix.os }})
58+
runs-on: ${{ matrix.os }}
59+
strategy:
60+
fail-fast: false
61+
matrix:
62+
os: [ubuntu-latest, windows-latest]
5563
steps:
5664
- uses: actions/checkout@v4
5765

@@ -86,8 +94,12 @@ jobs:
8694
run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
8795

8896
test-cloudflare:
89-
name: cargo check (cloudflare native + wasm32-unknown-unknown)
90-
runs-on: ubuntu-latest
97+
name: cargo check (cloudflare native + wasm32-unknown-unknown, ${{ matrix.os }})
98+
runs-on: ${{ matrix.os }}
99+
strategy:
100+
fail-fast: false
101+
matrix:
102+
os: [ubuntu-latest, windows-latest]
91103
steps:
92104
- uses: actions/checkout@v4
93105

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
/spin
1717
/spin.sig
1818

19+
# logs
20+
*.log
21+
1922
# EdgeZero local KV store (created by edgezero-adapter-axum framework)
2023
.edgezero/
2124

CLAUDE.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ crates/
1919
trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary)
2020
trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component)
2121
trusted-server-cli/ # Host-target `ts` operator CLI
22+
device/
23+
fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider)
24+
edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC default is in core)
25+
geo/ # vendor geo provider crates (host geo is injected by the adapter)
2226
trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles
2327
lib/ # TS source, Vitest tests, esbuild pipeline
2428
```
@@ -57,7 +61,9 @@ fastly compute serve
5761
# Deploy to Fastly
5862
fastly compute publish
5963

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

6369
# Test Axum adapter only
@@ -138,6 +144,20 @@ cd crates/trusted-server-js/lib && node build-all.mjs
138144
cargo install viceroy --version 0.17.0 --locked --force
139145
```
140146

147+
### Windows (use WSL for the Linux-only tests)
148+
149+
The Rust adapter tests run natively on Windows through the cargo aliases
150+
(`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`),
151+
and CI runs these on both `ubuntu-latest` and `windows-latest`.
152+
153+
The Docker-based integration suite (`scripts/integration-tests.sh`) and the
154+
Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`,
155+
which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them
156+
inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the
157+
WSL distro with the same toolchain as `.tool-versions` (rustup + the
158+
`wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then
159+
run the scripts from a clone on the WSL native filesystem for fast builds.
160+
141161
---
142162

143163
## Coding Conventions
@@ -265,12 +285,34 @@ impl core::error::Error for MyError {}
265285

266286
## Other guidelines
267287

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

299+
### Permission model terminology
300+
301+
Permissions are the primitive. A provider declares the permissions it requires
302+
(`required_permissions`) and the system decides whether each is _set_. Consent
303+
is only one of several ways a permission may be established. Country or
304+
jurisdiction rules (a `Granted` group baseline), legitimate interest, or
305+
configuration can set a permission with no consent at all.
306+
307+
- A provider that needs nothing **requires no permission**. Never write that it
308+
"runs without any consent".
309+
- A gated provider **runs once its required permissions are set**, by whatever
310+
method.
311+
- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`,
312+
GDPR and TCF strings) where it genuinely means a consent signal. In the
313+
permission layer prefer "permission", "set" / "unset", and "signal" (consent
314+
is one kind of signal, alongside privacy and opt-out signals).
315+
274316
---
275317

276318
## Git Commit Conventions
@@ -287,6 +329,41 @@ Bad: `"fix: added feature flags"`
287329

288330
---
289331

332+
## Provider Architecture
333+
334+
Each vendor-differentiated capability is pluggable behind its own trait, so a
335+
deployment selects an implementation and the core stays neutral:
336+
337+
| Capability | Trait | Selector | Built-in (core) | Vendor / host crates |
338+
| --------------------- | ---------------------------------------- | ------------------- | --------------------------------------- | ---------------------------- |
339+
| Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/<vendor>` |
340+
| Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/<vendor>` |
341+
| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/<vendor>` |
342+
343+
Principles for adding or changing a provider:
344+
345+
- **Core stays neutral.** The trait and the host-neutral default live in
346+
`trusted-server-core`. Host-specific and vendor implementations live in their
347+
own crates and are injected by the adapter (for example `build_device_provider`
348+
and `build_geo_provider`), so core never depends on a host SDK or a vendor, and
349+
the default request path makes no host-specific calls.
350+
- **Providers read request evidence, not a fixed parameter set.** A provider must
351+
be able to see everything about the request it needs (User-Agent, headers, and
352+
host signals such as the TLS JA4 and HTTP/2 fingerprints) through an evidence
353+
abstraction rather than a hard-coded struct of fields. Host signals come from
354+
the host (the Fastly SDK) and are opt-in, so a neutral provider triggers no
355+
host fingerprint calls.
356+
- **Providers are separated by capability but composed per request, and one may
357+
need another's output.** Geo resolves the country and region the permission
358+
model uses, and the permission model gates whether the Edge Cookie provider
359+
runs. Device signals gate Edge Cookie writes (the browser / bot gate). When
360+
several vendor providers share a backend (for example a vendor's Edge Cookie,
361+
geo, and device provider on one cloud pipeline) they share a single call per
362+
request rather than calling independently. Give a provider the inputs and
363+
upstream results it needs explicitly, rather than having it reach into globals.
364+
365+
---
366+
290367
## Integration System
291368

292369
Integrations register in Rust via:
@@ -320,7 +397,7 @@ IntegrationRegistration::builder(ID)
320397
| --------------------- | ---------------------------------------------------------- |
321398
| `edgezero.toml` | EdgeZero app/platform manifest and logical stores |
322399
| `fastly.toml` | Fastly service configuration and build settings |
323-
| `trusted-server.example.toml` | Source-controlled Trusted Server app-config template |
400+
| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and `[geo] default_country`) |
324401
| `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope |
325402
| `rust-toolchain.toml` | Pins Rust version to 1.95.0 |
326403
| `.env.dev` | Local development environment variables |

Cargo.lock

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ members = [
99
"crates/trusted-server-cli",
1010
"crates/trusted-server-js",
1111
"crates/trusted-server-openrtb",
12+
"crates/device/fastly",
13+
"crates/geo/fastly",
1214
]
1315
# trusted-server-integration-tests is intentionally excluded from workspace members because it
1416
# requires a native target (testcontainers, reqwest) while the workspace default
@@ -77,6 +79,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
7779
scraper = "0.24.0"
7880
serde = { version = "1.0", features = ["derive"] }
7981
serde_json = "1.0.149"
82+
serde_yaml_ng = "0.10"
8083
simple_logger = "5"
8184
sha2 = "0.10.9"
8285
spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] }
@@ -87,6 +90,8 @@ tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time
8790
toml = "1.1"
8891
tower = "0.4"
8992
trusted-server-core = { path = "crates/trusted-server-core" }
93+
trusted-server-device-fastly = { path = "crates/device/fastly" }
94+
trusted-server-geo-fastly = { path = "crates/geo/fastly" }
9095
url = "2.5.8"
9196
urlencoding = "2.1"
9297
uuid = { version = "1.18", features = ["v4"] }

crates/device/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Device providers
2+
3+
Device-detection provider crates live here, one per vendor. The Fastly provider
4+
(`trusted-server-device-fastly`) classifies a request with the host's TLS and
5+
HTTP/2 fingerprints; future vendor providers (for example
6+
`crates/device/<vendor>`) slot in alongside it.
7+
8+
The built-in default provider (User-Agent only) ships in `trusted-server-core`
9+
(`ec::device`). Adapters select and inject the vendor provider via
10+
`build_device_provider`.

crates/device/fastly/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "trusted-server-device-fastly"
3+
version = "0.1.0"
4+
authors = []
5+
edition = "2024"
6+
publish = false
7+
license = "Apache-2.0"
8+
9+
[lib]
10+
doctest = false
11+
12+
[lints]
13+
workspace = true
14+
15+
[dependencies]
16+
trusted-server-core = { workspace = true }
17+
fastly = { workspace = true }

0 commit comments

Comments
 (0)