Skip to content

Commit b502592

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 0e5dbb2 commit b502592

67 files changed

Lines changed: 5562 additions & 944 deletions

Some content is hidden

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

.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

@@ -92,8 +100,12 @@ jobs:
92100
run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
93101

94102
test-cloudflare:
95-
name: cargo check (cloudflare native + wasm32-unknown-unknown)
96-
runs-on: ubuntu-latest
103+
name: cargo check (cloudflare native + wasm32-unknown-unknown, ${{ matrix.os }})
104+
runs-on: ${{ matrix.os }}
105+
strategy:
106+
fail-fast: false
107+
matrix:
108+
os: [ubuntu-latest, windows-latest]
97109
steps:
98110
- uses: actions/checkout@v4
99111

.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
/dist/prebid/

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
@@ -141,6 +147,20 @@ cd crates/trusted-server-js/lib && node build-all.mjs
141147
cargo install viceroy --version 0.17.0 --locked --force
142148
```
143149

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

146166
## Coding Conventions
@@ -268,12 +288,34 @@ impl core::error::Error for MyError {}
268288

269289
## Other guidelines
270290

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

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

279321
## Git Commit Conventions
@@ -290,6 +332,41 @@ Bad: `"fix: added feature flags"`
290332

291333
---
292334

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

295372
Integrations register in Rust via:
@@ -323,7 +400,7 @@ IntegrationRegistration::builder(ID)
323400
| --------------------- | ---------------------------------------------------------- |
324401
| `edgezero.toml` | EdgeZero app/platform manifest and logical stores |
325402
| `fastly.toml` | Fastly service configuration and build settings |
326-
| `trusted-server.example.toml` | Source-controlled Trusted Server app-config template |
403+
| `trusted-server.example.toml` | Source-controlled app-config template (includes the `[ec]` / `[geo]` / `[device]` provider selectors and `[geo] default_country`) |
327404
| `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope |
328405
| `rust-toolchain.toml` | Pins Rust version to 1.95.0 |
329406
| `.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
@@ -1,6 +1,8 @@
11
[workspace]
22
resolver = "2"
33
members = [
4+
"crates/device/fastly",
5+
"crates/geo/fastly",
46
"crates/trusted-server-adapter-axum",
57
"crates/trusted-server-adapter-cloudflare",
68
"crates/trusted-server-adapter-fastly",
@@ -92,6 +94,7 @@ rustls-pemfile = "2"
9294
scraper = "0.24.0"
9395
serde = { version = "1.0", features = ["derive"] }
9496
serde_json = "1.0.149"
97+
serde_yaml_ng = "0.10"
9598
sha2 = "0.10.9"
9699
simple_logger = "5"
97100
spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] }
@@ -106,6 +109,8 @@ toml = "1.1"
106109
toml_edit = "0.23.10"
107110
tower = "0.4"
108111
trusted-server-core = { path = "crates/trusted-server-core" }
112+
trusted-server-device-fastly = { path = "crates/device/fastly" }
113+
trusted-server-geo-fastly = { path = "crates/geo/fastly" }
109114
trusted-server-js = { path = "crates/trusted-server-js" }
110115
trusted-server-openrtb = { path = "crates/trusted-server-openrtb" }
111116
url = "2.5.8"

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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "trusted-server-device-fastly"
3+
description = "Fastly host device provider exposing opt-in TLS and HTTP/2 signals."
4+
authors = { workspace = true }
5+
edition = { workspace = true }
6+
license = { workspace = true }
7+
publish = { workspace = true }
8+
version = { workspace = true }
9+
10+
[lib]
11+
doctest = false
12+
13+
[lints]
14+
workspace = true
15+
16+
[dependencies]
17+
trusted-server-core = { workspace = true }
18+
fastly = { workspace = true }

0 commit comments

Comments
 (0)