From 3ff6054575ffc33f374a3b9e33f5b1576acfd509 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 19 Aug 2026 15:09:59 +0100 Subject: [PATCH 01/19] Add the provider documentation set and finish the decomposition Fifth and final slice of the PR 838 decomposition: - Documentation for the provider model: configuration reference for the [ec], [device], and [geo] sections (including the required default_country, the assume_single_jurisdiction acknowledgment, and the requires-signal floor on a failed lookup), the Edge Cookie guide rewritten around providers and the permission model (including the narrow withdrawal semantics, the hardened resolve endpoint, and the resolved-marker cookie), setup and error-reference updates, and the permission-model guide joins the docs navigation. - The example configuration documents every provider selector in one place, with the [geo] baseline explained and acknowledged. - The HostSignals trait moves to the top of the evidence module with its service framing documented. - The Fastly EC lifecycle integration-test job joins the test workflow. The earlier draft of this slice carried an IntegrationResponseMutator response-header hook. It shipped with no consumer, so it is not included: the hook returns together with the first integration that needs it. Two specs land with this slice: the migration and rollout spec (docs/superpowers/specs/2026-07-30-provider-migration-rollout-design.md), its sign-off ledger kept intact with each row the series implements marked with its PR, and the response-header hook spec (docs/superpowers/specs/2026-07-30-integration-response-header-hook-design.md), retained as the design bar for the hook when its first consumer arrives. --- .github/workflows/test.yml | 16 ++- .gitignore | 3 + crates/geo/README.md | 5 +- crates/trusted-server-core/src/evidence.rs | 44 +++--- .../src/integrations/ec_client_fixed/index.ts | 3 +- docs/guide/configuration.md | 131 +++++++++++++++--- docs/guide/ec-setup-guide.md | 6 +- docs/guide/edge-cookies.md | 106 ++++++++------ docs/guide/error-reference.md | 10 +- docs/guide/fastly.md | 5 +- docs/guide/key-rotation.md | 2 +- trusted-server.example.toml | 25 +++- 12 files changed, 259 insertions(+), 97 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7a145e020..9d01a55a96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,8 +65,12 @@ jobs: run: BID_DELAY=3 ./scripts/template-cache-local-test.sh inline test-axum: - name: cargo test (axum native) - runs-on: ubuntu-latest + name: cargo test (axum native, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -107,8 +111,12 @@ jobs: run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 test-cloudflare: - name: cargo check (cloudflare native + wasm32-unknown-unknown) - runs-on: ubuntu-latest + name: cargo check (cloudflare native + wasm32-unknown-unknown, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 24b9e06aa1..a4a0af36c7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ /spin /spin.sig +# logs +*.log + # EdgeZero local KV store (created by edgezero-adapter-axum framework) .edgezero/ /dist/prebid/ diff --git a/crates/geo/README.md b/crates/geo/README.md index f3c5d11fb5..af09b0012a 100644 --- a/crates/geo/README.md +++ b/crates/geo/README.md @@ -12,8 +12,9 @@ implementing the `PlatformGeo` trait from `trusted-server-core`: Whatever the source, a provider 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 providers feed the -same downstream rules without translation. +code with no country prefix (`CA`). The permission model keys its country and +region rules on these codes, matched case-insensitively, so the Fastly and +other providers feed the same rules without translation. The platform-neutral `PlatformGeo` trait and the `DisabledGeo` default (no location) both live in `trusted-server-core`, so the default deployment resolves diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index ed079e7d49..f02b43bb19 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -7,13 +7,35 @@ //! per-request is stored on the provider, so the same provider serves every //! request. //! -//! These traits are those views. Request-scoped data outlives the live request -//! only when snapshotted, so an implementation owns its data where needed. -//! [`BorrowedRequestInfo`] is the borrowed view core builds on the request -//! path, and [`OwnedRequestInfo`] is the built-in owned snapshot. +//! These traits are the service interfaces. Concrete implementations live with +//! the host or vendor that supplies them (for example `FastlyHostSignals` in +//! `trusted-server-device-fastly`). An injected service outlives the borrow of +//! the live request, so an implementation owns its data ([`OwnedRequestInfo`] is +//! the built-in owned snapshot, while [`BorrowedRequestInfo`] is the borrowed +//! view core builds on the request path and must not outlive the request). +//! +//! [`RequestInfo`] describes what a request carries, not what code in this +//! repository happens to read today, so it carries the whole of it whether or +//! not anything here reads it yet. What a provider may see is not the control. +//! What a provider may do with what it sees is the control, and that is the +//! permission model. use http::HeaderMap; +/// Host-computed client fingerprints that are not carried in request headers. +/// +/// A host that can compute them supplies an implementation (Fastly exposes the +/// TLS JA4 and HTTP/2 fingerprints). A provider that needs them takes +/// `Arc` in its constructor, and on a host that supplies none +/// the provider cannot be built and the request stops. +pub trait HostSignals: Send + Sync + core::fmt::Debug { + /// The full JA4 TLS fingerprint, or `None` when unavailable. + fn ja4(&self) -> Option<&str>; + + /// The raw HTTP/2 SETTINGS fingerprint, or `None` when unavailable. + fn h2(&self) -> Option<&str>; +} + /// Read-only access to the current request's basic information. /// /// The request data any host can supply: the normalized client IP, the @@ -236,20 +258,6 @@ impl RequestInfo for BorrowedRequestInfo<'_> { } } -/// Host-computed client signals that are not carried in request headers. -/// -/// A host that can compute them supplies an implementation (Fastly exposes the -/// TLS JA4 and HTTP/2 signals). A provider that needs them takes -/// `Arc` in its constructor, and on a host that supplies none the -/// provider cannot be built and the request stops. -pub trait HostSignals: Send + Sync + core::fmt::Debug { - /// The full JA4 TLS signal, or `None` when unavailable. - fn ja4(&self) -> Option<&str>; - - /// The raw HTTP/2 SETTINGS signal, or `None` when unavailable. - fn h2(&self) -> Option<&str>; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts index 04cd0322fb..2dabafb522 100644 --- a/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts @@ -3,7 +3,8 @@ // Client and server share one fixed, known word. When the resolved marker is // absent, this posts that word to the resolve endpoint. With the `client-fixed` // provider selected, the server verifies the word and, on a match, persists the -// identity-graph row and sets the word as an HttpOnly Edge Cookie on the +// identity-graph row and sets the coded form of the word (cfix~an-ec) as an +// HttpOnly Edge Cookie on the // response, together with a non-HttpOnly marker cookie. The Edge Cookie itself // is HttpOnly, so this script can never see it; the marker is what tells it a // resolve already succeeded, so it does not post again on every page view. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a2b90b240b..72876096e0 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -24,6 +24,9 @@ origin_url = "https://origin.publisher.com" proxy_secret = "your-secure-secret-here" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "replace-with-32-plus-byte-random-secret" ``` @@ -37,7 +40,8 @@ read by the deployed application at request time. # Format: TRUSTED_SERVER__SECTION__FIELD export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -export TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +export TRUSTED_SERVER__EC__PROVIDER=hmac +export TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=replace-with-32-plus-byte-random-secret ts config validate ts config push --adapter fastly @@ -61,6 +65,7 @@ fail and the service will return its startup-error response. | File | Purpose | | --------------------- | ------------------------------- | | `trusted-server.toml` | Main application configuration | +| `permissions.yaml` | Country/region permission rules | | `fastly.toml` | Fastly Compute service settings | | `.env.dev` | Local development overrides | @@ -72,6 +77,8 @@ fail and the service will return its startup-error response. | `[trusted_client_ip]` | Authenticated client-IP forwarding | | `[ec]` | Edge Cookie (EC) ID generation | | `[tester_cookie]` | Optional tester-cookie endpoint | +| `[device]` | Device classification provider selection | +| `[geo]` | Geolocation provider selection | | `[proxy]` | Proxy SSRF allowlist and asset routes | | `[cache]` | Static/rehosted asset cache policy rules | | `[image_optimizer]` | Reusable Image Optimizer profile sets | @@ -89,6 +96,9 @@ origin_url = "https://origin.publisher.com" proxy_secret = "change-me-to-secure-value" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "replace-with-32-plus-byte-random-secret" [request_signing] @@ -502,14 +512,24 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ### `[ec]` -| Field | Type | Required | Description | -| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------- | -| `passphrase` | String | Yes | Publisher passphrase used as HMAC key | -| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | -| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | -| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | -| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | -| `partners` | Array | No | Static partner registry entries | +| Field | Type | Required | Description | +| ------------------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Key of the active Edge Cookie provider, for example `"hmac"`. Omit to run statelessly with no Edge Cookie | +| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | +| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | +| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | +| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | +| `partners` | Array | No | Static partner registry entries | + +The selected `provider` must have a matching `[ec.providers.]` block. Selecting a provider with no configured block, or an unknown key, fails at startup. + +### `[ec.providers.hmac]` + +The built-in HMAC-over-client-IP provider, keyed `hmac`. + +| Field | Type | Required | Description | +| ------------ | ------ | --------------------------- | ----------------------------------------- | +| `passphrase` | String | Yes when `hmac` is selected | Publisher passphrase used as the HMAC key | ::: tip Partner keying `source_domain` is the canonical partner key. It matches incoming OpenRTB EID `source` values and is also used as the EC KV `ids` map key. @@ -519,9 +539,12 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +provider = "hmac" ec_store = "ec_identity_store" +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" + [[ec.partners]] name = "Mocktioneer SSP" source_domain = "mocktioneer.example" @@ -532,19 +555,28 @@ bidstream_enabled = true **Environment Override**: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=your-secret +TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=your-secret TRUSTED_SERVER__EC__EC_STORE=ec_identity_store ``` +These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environment values into the published configuration (for example test harnesses building an app-config blob). The running server reads its settings from the platform config store, so provider selection changes take effect when a new configuration is pushed, not per request. + ### Field Details -#### `passphrase` +#### `provider` + +**Purpose**: Names the active Edge Cookie provider by its key. Omit to run statelessly with no Edge Cookie. + +**Validation**: Application startup fails if the selected key has no matching `[ec.providers.]` block, or is unknown. -**Purpose**: Publisher passphrase used as HMAC key for EC ID generation. +#### `providers.hmac.passphrase` + +**Purpose**: Publisher passphrase used as HMAC key for EC ID generation, read when `provider = "hmac"`. **Security**: -- Must be non-empty +- At least 32 characters - Rotate periodically for security - Store securely (environment variable recommended) @@ -558,6 +590,68 @@ openssl rand -hex 32 **Validation**: Application startup fails if: - Empty string +- Shorter than 32 characters + +## Device Configuration + +Selects how a request is classified into the coarse device signals the Edge Cookie bot gate uses, mirroring the Edge Cookie provider selection. These signals serve identifier gating and bot detection, not bid enrichment. + +### `[device]` + +| Field | Type | Required | Description | +| ---------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Key of the device-detection provider. Defaults to `builtin` (User-Agent only, no host-specific call). Set `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers | + +The default `builtin` provider classifies from the User-Agent alone and makes no host-specific call, so the default path stays host-neutral. Selecting an unknown provider key fails at startup. + +**Example**: + +```toml +[device] +provider = "builtin" # or "fastly" to add TLS and HTTP/2 evidence +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__DEVICE__PROVIDER=builtin +``` + +## Geo Configuration + +Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie provider selection. The resolved country also feeds the [permission model](/guide/permission-model). + +### `[geo]` + +| Field | Type | Required | Description | +| ---------------------------- | -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `provider` | String or null | No | Key of the geo provider. Omit to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | +| `default_country` | String | Yes | Country (`US`) or country/region (`US/CA`) whose `permissions.yaml` rule applies when geo returns no country, or a country/region with no rule. Required, so there is always a default permission set and startup fails when unset. Validated at startup. | +| `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request is treated as `default_country`. A deployment that runs an Edge Cookie provider without a geo provider must set this to `true`, acknowledging single-jurisdiction operation; startup fails otherwise. Not needed when a geo provider is selected or no Edge Cookie provider is configured. | + +No provider is the default, so a default deployment is not tied to any host geo service. Selecting an unknown provider key fails at startup. A failed geo lookup at request time does not fall back to `default_country`; it resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. + +**Example**: + +```toml +[geo] +provider = "platform" +default_country = "US" +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__GEO__PROVIDER=platform +``` + +## Provider Permissions + +A provider advertises the technical permissions its data use requires, and Trusted Server runs the provider only when every required permission is set. This separates legal policy from the core, so the deployer brings the policy that decides how permissions are established. See the [Permission Model](/guide/permission-model) for the concept, the permission vocabulary, and how a request resolves. + +### Country and region rules (`permissions.yaml`) + +The country and region permission rules are defined in a human-editable `permissions.yaml` at the repository root, compiled into the build (not loaded at runtime). Edit that file and rebuild to change the policy. There is no `[permissions]` block in `trusted-server.toml`. It defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule uses the required `[geo] default_country` set in `trusted-server.toml`. See the [Permission Model](/guide/permission-model) for the schema and the shipped defaults. ## Response Headers @@ -1929,8 +2023,9 @@ Configuration is validated at startup: **EC Validation**: -- `passphrase` ≥ 1 character -- `passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"` — case-insensitive) +- `provider`, when set, names a provider with a matching `[ec.providers.]` block; an unknown or unconfigured selection fails at startup +- `providers.hmac.passphrase` ≥ 32 characters +- `providers.hmac.passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"`, case-insensitive) **Handler Validation**: @@ -1987,7 +2082,7 @@ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret_staging) ```bash # All secrets from environment TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret) -TRUSTED_SERVER__EC__PASSPHRASE=$(cat /run/secrets/ec_secret) +TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=$(cat /run/secrets/ec_secret) TRUSTED_SERVER__HANDLERS__0__PASSWORD=$(cat /run/secrets/admin_password) ``` @@ -2041,7 +2136,7 @@ trusted-server.dev.toml # Development overrides **"Configuration field '...' is set to a known placeholder value"**: -- `ec.passphrase` cannot be `"secret-key"`, `"secret_key"`, or `"trusted-server"` (case-insensitive) +- `ec.providers.hmac.passphrase` cannot be `"secret-key"`, `"secret_key"`, or `"trusted-server"` (case-insensitive) - `publisher.proxy_secret` cannot be `"change-me-proxy-secret"` (case-insensitive) - Must be non-empty - Change to a secure random value (see generation commands above) diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index a11a352a89..35065213bb 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -23,9 +23,12 @@ Set EC configuration in `trusted-server.toml`: ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +provider = "hmac" ec_store = "ec_identity_store" +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" + [[ec.partners]] name = "Mocktioneer SSP" source_domain = "formally-vital-lion.edgecompute.app" @@ -35,6 +38,7 @@ bidstream_enabled = true Required behavior assumptions: +- `provider = "hmac"` selects the built-in HMAC provider; its `passphrase` lives under `[ec.providers.hmac]` - `passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters - `ec_store` is linked to the active Fastly service version - `ec_store` is the only KV-backed EC lifecycle store; it contains identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 2ba9f2638b..eef24ed0e5 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -8,12 +8,9 @@ permit EC use. ## Policy Posture -Trusted Server is technology. It is neutral on policy. The Edge Cookie -gives the deployer a cookie slot and configuration over the surrounding -attributes. The deployer determines the policy posture based on the -laws and contractual arrangements that apply to their deployment. -Privacy outcomes follow from that configuration, not from the cookie -mechanism itself. +Trusted Server is technology. It is neutral on policy. The Edge Cookie gives the deployer a cookie slot and configuration over the surrounding attributes. The deployer determines the policy posture based on the laws and contractual arrangements that apply to their deployment. Privacy outcomes follow from that configuration, not from the cookie mechanism itself. + +An Edge Cookie (EC) is a first-party identifier that the built-in provider derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server surfaces the current EC ID via response headers and a first-party cookie. For the exact header and cookie names, see the [API Reference](/guide/api-reference). For full operational onboarding (partner configuration, batch sync, identify, and auction verification), use the [EC Setup Guide](/guide/ec-setup-guide). @@ -42,7 +39,7 @@ maps to a stable base. ### Request Lifecycle -Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction) — read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds consent from request-local cookies, headers, geolocation, and policy defaults; it does not load consent from a separate KV store. +Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction). Read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds the consent context from request-local cookies, headers, geolocation, and policy defaults. It does not load consent from a separate KV store. ```mermaid sequenceDiagram @@ -60,7 +57,7 @@ sequenceDiagram Note over TS: Phase 3: Finalize
Ingest Prebid EID cookies TS-->>B: Response + Set-Cookie: ts-ec=... else Return Visit (EC cookie present) - Note over TS: Phase 2: Routing
EC exists — skip generation + Note over TS: Phase 2: Routing
EC exists, skip generation Note over TS: Phase 3: Finalize
Ingest Prebid EID cookies TS-->>B: Response
(no cookie refresh) end @@ -70,11 +67,11 @@ sequenceDiagram ### Response Finalization -After routing completes, the server evaluates consent state and cookie presence to decide what to do with the EC cookie on the response. +After routing completes, the server evaluates the permission state and cookie presence to decide what to do with the EC cookie on the response. ```mermaid flowchart TD - Start[ec_finalize_response] --> ConsentCheck{Consent
allows EC?} + Start[ec_finalize_response] --> ConsentCheck{Permissions
allow EC?} ConsentCheck -- "No" --> ExplicitWithdrawal{Explicit
withdrawal?} ExplicitWithdrawal -- "Yes" --> CookiePresent{Cookie was
present?} @@ -87,44 +84,71 @@ flowchart TD WasPresent -- "No, just generated" --> NewEc["Ingest Prebid EID cookies
Set ts-ec cookie"] ``` -When consent cannot be verified for the current request — for example, unknown jurisdiction or missing/undecodable consent signals in a regulated region — Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. +When the required permissions cannot be established for the current request (for example an unknown country with no configured default, or missing or undecodable consent signals), Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. + +## Permission Gating + +EC creation is gated through the [permission model](/guide/permission-model), not by a jurisdiction rule baked into the core. The Edge Cookie provider advertises the permissions its data use requires, and Trusted Server creates an Edge Cookie only when every required permission is set. The built-in HMAC provider requires `necessary.operations.storage` (TCF Purpose 1), because the `Set-Cookie` operation stores information on the device. + +The Edge Cookie code never reads consent. It checks only whether the required **permission** is set. Consent is one of the sources that _set_ a permission, not something the gate reads directly, so the Edge Cookie logic does not change when a consent framework changes. Two sources combine for each request: + +- **A country and region baseline.** The country, and an optional region such as a US state, that the geo provider returns. A region rule takes precedence over its country, and when no country is identified, or the country/region has no rule, the configured default country applies. +- **Consent and privacy signals.** TCF, GPP, and GPC (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`) decoded from the request and mapped onto permissions as a **grant or a revoke** on top of that baseline. There is no separate consent KV fallback. + +Today only `necessary.operations.storage` is resolved this way: its country and region baseline is adjusted by the incoming TCF signal, and the Edge Cookie is created only when the result is set. With no configured default country, an unknown country sets nothing without a signal, so the cookie is not created unless a signal grants the permission. The core encodes no jurisdiction's law. The deployer brings the policy, and the per-country and per-region rules are configuration rather than core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. + +```mermaid +flowchart TD + Start[Resolve country and region] --> Baseline[Country or region rule,
else the default country] + Baseline --> Signals[Apply consent/privacy signals
as a grant or revoke] + Signals --> Check{Provider's required
permissions all set?} + Check -- "Yes" --> Allow([Create EC]) + Check -- "No" --> Deny([No EC]) +``` + +The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, and withdrawal tombstones. Permission resolution for each request is based on the live request signals listed above. + +## Provider Types: Server-Side and Client-Side -## Consent Model +The Edge Cookie identifier comes from a configurable provider, selected by `[ec] provider`. A provider is one of two types, and the permission gate above applies to both. The two reach the **same outcome** (a `ts-ec` cookie set and carried on every later request) by **different routes**. -EC creation is gated by jurisdiction. The server detects jurisdiction from geolocation data attached to the request and applies the corresponding consent rules. Live consent comes from request-local signals (`euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy`, `Sec-GPC`) plus geolocation and policy defaults; there is no separate consent KV fallback. +- **Server-side** (for example the built-in HMAC provider, or the built-in `host-signals` provider that mints from the host's TLS JA4 and HTTP/2 fingerprints on a host that supplies them). The provider derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved. +- **Client-side** (for example the `client-fixed` demo). The provider cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the provider's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The provider mints from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. ```mermaid flowchart TD - Start[Detect Jurisdiction] --> J{Jurisdiction?} - - J -- "GDPR
(EU/UK)" --> TCF{TCF string
present?} - TCF -- "Yes" --> P1{Purpose 1
granted?} - P1 -- "Yes" --> Allow([Allow EC]) - P1 -- "No" --> Deny([Deny EC]) - TCF -- "No" --> Deny - - J -- "US State" --> GPC{GPC header
set?} - GPC -- "Yes" --> Deny - GPC -- "No" --> USTCF{TCF from CMP
e.g. Didomi?} - USTCF -- "Yes" --> USP1{Purpose 1
granted?} - USP1 -- "Yes" --> Allow - USP1 -- "No" --> Deny - USTCF -- "No" --> USP{US Privacy
string?} - USP -- "Yes" --> OptOut{Opt-out
sale?} - OptOut -- "No" --> Allow - OptOut -- "Yes" --> Deny - USP -- "No" --> Deny - - J -- "Non-regulated" --> Allow - J -- "Unknown
(no geo data)" --> Deny + Start(["Page request, no Edge Cookie"]) --> Type{"Provider type"} + + Type -->|"Server-side (e.g. HMAC)"| SGen["generate() mints at the edge from request data"] + SGen --> SSet["Page response sets ts-ec"] + + Type -->|"Client-side (e.g. client-fixed)"| CDefer["generate() defers, returns no identifier"] + CDefer --> CPage["Page response, no cookie, delivers the provider JS"] + CPage --> CBox[["Provider JS (black box): runs in the browser and does its work"]] + CBox --> CPost["JS posts the result to POST /_ts/api/v1/ec/resolve"] + CPost --> CResolve["resolve_from_client() verifies and mints"] + CResolve --> CSet["Resolve response sets ts-ec"] + + SSet --> Same(["Same outcome: ts-ec set, carried on every later request"]) + CSet --> Same ``` -- **GDPR**: Opt-in required. TCF Purpose 1 (store/access device) must be explicitly consented. -- **US State**: Opt-out model with three-tier fallback — GPC always blocks, then TCF if a CMP uses it, then US Privacy string, then fail-closed. -- **Non-regulated**: EC always allowed. -- **Unknown**: Fail-closed when jurisdiction cannot be determined. +The two types differ only in route and in the methods they use: + +| Feature | Server-side | Client-side | +| -------------------- | ------------------------- | ---------------------------------------------- | +| Example | HMAC (`hmac`) | `client-fixed` (demo) | +| Mints in | `generate()`, at the edge | `resolve_from_client()`, from the posted value | +| `generate()` returns | the identifier | no identifier (defers) | +| Client JavaScript | none | the provider JS (black box), which posts back | +| Endpoint | none | `POST /_ts/api/v1/ec/resolve` | +| Cookie set on | the page response | the resolve response | + +The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A minted identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-minted one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back; a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client-fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. + +Because the posted value comes from the browser, **verification is the provider's responsibility**. A client-side provider must verify the payload (for example a signature) before minting, or a client could forge an Edge Cookie. The endpoint itself is provider-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the provider, and writes the cookie. -The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, and withdrawal tombstones. Consent interpretation for each request remains based on the live request signals listed above. +A built-in `client-fixed` provider demonstrates the client-side type end to end with no vendor coupling. Client and server share one fixed, known word. When no Edge Cookie is present, the page script (shipped in the tsjs bundle when that provider is selected) posts that word, the server verifies it matches, and on a match sets it as the Edge Cookie. The value is verifiable because it is a known constant, which is the point of the demo. It is useless in production, because a fixed value is not an identity, so it is for demonstration and testing only. ## Partner Sync Channels @@ -285,7 +309,7 @@ sets `Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, and a `Max-Age`. - Returning requests with consent and an existing `ts-ec` do not refresh the EC cookie or KV TTL. - Newly generated ECs receive `Set-Cookie: ts-ec=...`. -- When consent is blocked but not explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal. +- When the permission is not set but nothing was explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal. Withdrawal is deliberately narrow: a TCF record refusing storage in a jurisdiction whose baseline did not grant it. US-style opt-outs (GPC, a GPP sale opt-out, or a US Privacy opt-out) suppress use for the request but never expire the cookie or write a tombstone, so lifting the opt-out restores the identity. - `/_ts/api/v1/identify` is read-oriented and returns identity enrichment for the authenticated partner. It computes `cluster_size` only when the EC entry does not already store one. - `/_ts/api/v1/batch-sync` writes mappings into the EC identity graph. Mapping timestamps are retained for API compatibility but no longer order writes; valid mappings use idempotent last-write-wins semantics. - Pull sync fills missing partner UIDs only. Existing partner UIDs are not periodically refreshed because EC entries no longer store per-partner sync timestamps. diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index b5348ed9f3..070c07cb32 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -69,7 +69,7 @@ proxy_secret = "change-me-to-random-string" - `publisher.domain` - `publisher.origin_url` - `publisher.proxy_secret` -- `ec.passphrase` +- `ec.providers.hmac.passphrase` (when `ec.provider = "hmac"`) --- @@ -141,17 +141,21 @@ Failed to generate EC ID: HMAC error **Solution:** -1. Ensure `passphrase` is set in `trusted-server.toml`: +1. Ensure the `hmac` provider is selected and its `passphrase` is set in `trusted-server.toml`: ```toml [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "replace-with-32-plus-byte-random-secret" ``` 2. Or set via environment variable: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=replace-with-32-plus-byte-random-secret ``` --- diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 7ef1e8ae8c..b6b8691f7f 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -286,8 +286,11 @@ Configure in `trusted-server.toml`: ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +provider = "hmac" ec_store = "ec_identity_store" + +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" ``` Verify stores exist: diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md index ec067b004e..5e62e0fa92 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -28,7 +28,7 @@ Key rotation is the process of generating new signing keys and transitioning fro ## Edge Cookie HMAC Passphrase -The Edge Cookie `ec.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters; shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph. +The Edge Cookie `ec.providers.hmac.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters; shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph. ## Prerequisites diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0b76ab6200..935d11b58c 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -110,6 +110,14 @@ pull_sync_concurrency = 3 # [ec.providers.hmac] # passphrase = "replace-with-32-plus-byte-random-secret" +# Built-in host-signal provider block. Mints the identifier from the host's TLS +# JA4 and HTTP/2 fingerprints instead of the client IP, so it needs a host that +# supplies those signals (Fastly does). Uncomment it together with +# `provider = "host-signals"` above, and give it its own secret on the same +# terms as the hmac block. +# [ec.providers.host-signals] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Optional identity partners (SSP/DSP/identity vendors). Each needs a real, # non-placeholder api_token (>= 32 bytes) at deploy. Configure real partners via # private config, not this template. @@ -130,16 +138,19 @@ pull_sync_concurrency = 3 # OPTIONAL — Core features (disabled/omitted by default) # ============================================================================= -# Device detection provider selection. The built-in provider classifies from -# the User-Agent alone and makes no host-specific call. The opt-in "fastly" -# provider strengthens the browser/bot gate with the host's TLS/H2 signals. +# Device-detection provider. Selects how a request is classified into device +# signals (browser/bot gating). Default "builtin" classifies from the User-Agent +# alone and makes no host call, so a default deployment stays host-neutral. The +# opt-in "fastly" provider adds the host's TLS/H2 fingerprints to the gate. +# Override at deployment with TRUSTED_SERVER__device__provider. # [device] -# provider = "builtin" +# provider = "builtin" # or "fastly" to add TLS/H2 fingerprint evidence [geo] -# Geo / IP intelligence provider selection. No provider is the default: -# Trusted Server resolves no location and makes no host geo call. The host -# platform's own lookup is opt-in. +# Geo / IP intelligence provider. No provider is the default: Trusted Server +# resolves no geolocation and makes no host geo call, so a default deployment is +# not tied to any host geo service. Opt into the host lookup with +# provider = "platform". Override with TRUSTED_SERVER__geo__provider. # provider = "platform" # # The permission baseline for a request the geo provider leaves unmatched From 1f6eea437dba1b33fd48162ea225fe9040b5174c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 27 Aug 2026 16:09:50 +0100 Subject: [PATCH 02/19] Correct the documentation set against the code The rollout spec cited commit SHAs from a rebuilt branch, credited the series with a passphrase tightening that pre-dates it, said graph rows were keyed verbatim when the pre-series normalization already lowercased them, attributed the Fastly-only identity endpoints to PR 1046, and claimed a CLI regression test for the provider overrides that does not exist. The hook spec said the fastly device provider is startup-rejected (it ships opt-in), that integrations cannot touch response headers (request filters can name them) and that the wasm target builds with panic = abort (it has no unwinding at all). The configuration guide now lists every Edge Cookie and geo provider value, the API reference documents each resolve response and the coded identifier form, and the setup guide shows the hmac~ prefix a minted cookie carries. --- docs/guide/api-reference.md | 6 +++--- docs/guide/configuration.md | 20 ++++++++++---------- docs/guide/ec-setup-guide.md | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 0f605577eb..afe90b4433 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -192,11 +192,11 @@ Server-to-server batch sync endpoint for writing EC ID to partner UID mappings. Resolve endpoint for client-side Edge Cookie providers. The page posts a value that the provider verifies and creates the Edge Cookie value. Used only when a client-side provider is selected (for example the `client-fixed` demo). Server-side providers such as HMAC do not use it. -**Auth:** None. This is a first-party POST from the page. The provider is responsible for verifying the posted value before trusting it. +**Auth:** None, but the request must carry an `Origin` on the publisher's own domain (a foreign or missing `Origin` answers `403`). This is a first-party POST from the page. The provider is responsible for verifying the posted value before trusting it. **Request Body:** the provider's value, opaque to the core. For the `client-fixed` demo this is the fixed known word sent as `text/plain`. -**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) and the status is `200`. When the gate is closed, no client-side provider is configured, or the provider produces no identifier, the response is `204` with no cookie. An oversized body is rejected with `413`. +**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the identifier is written to the identity graph first, then the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) together with the `ts-ecr` marker cookie the page script can read, and the status is `200`. When the gate is closed, no client-side provider is configured, no identity graph is available, or the provider produces no identifier, the response is `204` with no cookie. Rejections: `403` for a missing or foreign `Origin`, `415` for a content type other than `text/plain` or `application/json`, `413` for an oversized body, `400` when the minted identifier is outside the identifier bounds, `409` when the request already carries a different identity, and `503` when the identity-graph write fails. Every response the handler builds carries `Cache-Control: no-store`. --- @@ -610,7 +610,7 @@ The examples below use fictional IDs and values only. ### GET /\_ts/admin/ec/`{id}` -Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format. The bare route uses the request's `ts-ec` cookie. +Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format, with or without the `hmac~` provider-code prefix a minted identifier carries. The bare route uses the request's `ts-ec` cookie. This lookup is implemented only by the Fastly adapter because the identity graph is stored in Fastly KV. Other adapters return `501 Not Implemented`. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 72876096e0..c608a8b5c2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -512,14 +512,14 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ### `[ec]` -| Field | Type | Required | Description | -| ------------------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------- | -| `provider` | String or null | No | Key of the active Edge Cookie provider, for example `"hmac"`. Omit to run statelessly with no Edge Cookie | -| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | -| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | -| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | -| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | -| `partners` | Array | No | Static partner registry entries | +| Field | Type | Required | Description | +| ------------------------- | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Key of the active Edge Cookie provider: `"hmac"` (built-in), `"host-signals"` (opt-in), or `"none"` (explicitly stateless). Omit to run statelessly with no Edge Cookie. The `"client-fixed"` demo needs the `client-fixed-demo` build feature | +| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | +| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | +| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | +| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | +| `partners` | Array | No | Static partner registry entries | The selected `provider` must have a matching `[ec.providers.]` block. Selecting a provider with no configured block, or an unknown key, fails at startup. @@ -625,7 +625,7 @@ Selects how a client IP is resolved into geolocation (country, region, coordinat | Field | Type | Required | Description | | ---------------------------- | -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `provider` | String or null | No | Key of the geo provider. Omit to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | +| `provider` | String or null | No | Key of the geo provider. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | | `default_country` | String | Yes | Country (`US`) or country/region (`US/CA`) whose `permissions.yaml` rule applies when geo returns no country, or a country/region with no rule. Required, so there is always a default permission set and startup fails when unset. Validated at startup. | | `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request is treated as `default_country`. A deployment that runs an Edge Cookie provider without a geo provider must set this to `true`, acknowledging single-jurisdiction operation; startup fails otherwise. Not needed when a geo provider is selected or no Edge Cookie provider is configured. | @@ -843,7 +843,7 @@ Startup fails when no handler covers an admin route. The dynamic `/_ts/admin/ec/{id}` route accepts any segment after `/_ts/admin/ec/`, and Basic Auth runs on the raw path before routing, so coverage cannot be inferred from ID-shaped samples: a pattern such as -`^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level +`^/_ts/admin/ec/hmac~[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level matcher (`^/_ts/admin`, or `^/_ts/admin/ec/` alongside the other admin patterns). diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index 35065213bb..661a21ad75 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -91,13 +91,13 @@ curl -si "${TS_BASE_URL}/" \ Look for: -- `Set-Cookie: ts-ec=<64hex.6chars>` +- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the provider code; pre-series cookies without it still resolve) ## 5) Batch Sync (S2S) Endpoint: `POST /_ts/api/v1/batch-sync` -Important: request field is `ec_id` (full `{64hex}.{6alnum}` value). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Valid mappings are idempotent last-write-wins: unchanged UIDs are accepted without a write, and different UIDs replace the stored value. +Important: request field is `ec_id` (the full value as issued, `hmac~{64hex}.{6alnum}`; the bare pre-series form is also accepted). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Valid mappings are idempotent last-write-wins: unchanged UIDs are accepted without a write, and different UIDs replace the stored value. ```bash BATCH_UID="${PARTNER_UID}-batch" From aba3399143fe2b87cbd23e4fead77644ca0f68ee Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 01:28:33 +0100 Subject: [PATCH 03/19] Remove the test fixture and import the evidence rewrite left behind The no-client-IP test now derives identity from the request query and cookies through `EvidenceCapturingProvider`, which left `NoClientIpProvider` with no remaining caller. It is deleted rather than left looking like a live fixture, and the branch builds without the dead-code warning that denied clippy. That test's comment claimed the provider receives the documented unavailable value, the empty string, but the rewrite no longer observes what the provider was handed, so nothing proved it. The comment now says only what the test asserts, which is that a provider reading other evidence still mints when the host can determine no client IP. Restoring the stronger assertion would mean recording the client IP the provider saw, which is a change to the fixture rather than to this cleanup. The `http::HeaderMap` import in the device tests was unused, because the one place needing the type names it in full. Addresses: crates/trusted-server-core/src/ec/mod.rs and crates/trusted-server-core/src/ec/device.rs, which carried a dead test provider, a comment asserting more than the test proved, and an unused import. --- crates/trusted-server-core/src/ec/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 9a133adeb3..92e249cb31 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1381,7 +1381,7 @@ mod tests { // The requirement for a client IP belongs to the provider that uses // one, not to core. A provider deriving identity from the request // query and cookies runs on a host that cannot determine a client IP, - // and receives the documented unavailable value, the empty string. + // and still mints. let provider = Arc::new(EvidenceCapturingProvider::default()); let mut settings = create_test_settings(); settings.ec.provider = Some(EcProviderSelection::from("evidence")); From 72542e3d851f599a8e3ea3fd6c48d8b9c2471693 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 07:32:44 +0100 Subject: [PATCH 04/19] Put back the check on what a provider is handed with no client IP The rewrite that replaced the old no-client-IP fixture left its comment claiming the provider receives the documented unavailable value while nothing asserted it, so the contract went unchecked and the comment said more than the test knew. The shared evidence-capturing provider now records the client IP it was given alongside the query parameter and cookie it already recorded, and the test asserts the empty string reaches it. A provider cannot decide how to behave on a host that cannot determine a client IP without knowing what absence looks like, so that value is part of the contract rather than an implementation detail. Verified by falsifying the expectation and watching the assertion report the real value it observed rather than passing regardless. Addresses: crates/trusted-server-core/src/ec/mod.rs, a comment claiming more than its test proved --- crates/trusted-server-core/src/ec/mod.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 92e249cb31..2564528d21 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1227,6 +1227,7 @@ mod tests { #[derive(Debug, Default)] struct EvidenceCapturingProvider { seen: std::sync::Mutex>, + seen_client_ip: std::sync::Mutex>, } impl EdgeCookieProvider for EvidenceCapturingProvider { @@ -1246,6 +1247,11 @@ mod tests { let query_id = request_info.query_param("id").unwrap_or_default(); let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); + *self + .seen_client_ip + .lock() + .expect("should lock the seen client IP") = + Some(request_info.client_ip().to_owned()); Ok(GeneratedEdgeCookie { id: Some("evidence-ec".to_owned()), response_headers: Vec::new(), @@ -1381,7 +1387,10 @@ mod tests { // The requirement for a client IP belongs to the provider that uses // one, not to core. A provider deriving identity from the request // query and cookies runs on a host that cannot determine a client IP, - // and still mints. + // and still creates an identifier. This also pins what the provider is + // handed in that case, which is the documented unavailable value rather + // than something else, because a provider cannot decide how to behave + // without knowing what absence looks like. let provider = Arc::new(EvidenceCapturingProvider::default()); let mut settings = create_test_settings(); settings.ec.provider = Some(EcProviderSelection::from("evidence")); @@ -1408,6 +1417,15 @@ mod tests { Some("t0ev~evidence-ec"), "the identifier should be committed with no client IP available" ); + assert_eq!( + provider + .seen_client_ip + .lock() + .expect("should lock the seen client IP") + .clone(), + Some(String::new()), + "a host that cannot determine a client IP should hand the provider the documented unavailable value, which is the empty string" + ); } #[test] From e28ffc8280831bca2ffb0a90de9ccb0e3fb7d1c9 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 07:49:12 +0100 Subject: [PATCH 05/19] Run the core library tests natively in CI as well as on WebAssembly The core library is exercised only through the WebAssembly adapter targets, which build with panic=abort. Their harness stops at the first failing test and reports every later one as never run, so a red build shows one failure when there may be several, and the ones it hides are invisible until the first is fixed and the suite is run again. This adds a host-target run of the same crate to the job that already runs the CLI and codegen tests natively, so every failure is reported at once. It costs one more compilation of a crate the job has already built for other targets. Addresses: .github/workflows/test.yml, core failures hidden behind panic=abort --- .github/workflows/test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d01a55a96..d56d643a57 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -107,6 +107,15 @@ jobs: run: | cargo test --package trusted-server-openrtb-codegen --target "$(rustc -vV | sed -n 's/host: //p')" + # The core library is otherwise only exercised through the WebAssembly + # targets, which build with panic=abort, so their harness stops at the + # first failing test and reports every later one as never run. A run that + # looks like a single failure can be several. This native run reports them + # all at once, which is what makes a red build readable. + - name: Run host-target core library tests + run: | + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/host: //p')" + - name: Verify Fastly WASM release build run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 From 5494e0fc33f6d888a1296870412150cb8626f640 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 10:50:06 +0100 Subject: [PATCH 06/19] Documentation and house-style cleanup for the response-hook and docs PR --- .github/workflows/test.yml | 6 +++--- crates/trusted-server-core/src/ec/mod.rs | 2 +- crates/trusted-server-core/src/evidence.rs | 8 ++++---- docs/guide/api-reference.md | 4 ++-- docs/guide/edge-cookies.md | 14 +++++++------- trusted-server.example.toml | 8 ++++---- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d56d643a57..3043870cc4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -107,11 +107,11 @@ jobs: run: | cargo test --package trusted-server-openrtb-codegen --target "$(rustc -vV | sed -n 's/host: //p')" - # The core library is otherwise only exercised through the WebAssembly + # The core library's unit tests otherwise run only on the WebAssembly # targets, which build with panic=abort, so their harness stops at the # first failing test and reports every later one as never run. A run that - # looks like a single failure can be several. This native run reports them - # all at once, which is what makes a red build readable. + # looks like a single failure can hide many more. This native run reports + # them all at once, which is what makes a red build readable. - name: Run host-target core library tests run: | cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/host: //p')" diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 2564528d21..74655c6999 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1424,7 +1424,7 @@ mod tests { .expect("should lock the seen client IP") .clone(), Some(String::new()), - "a host that cannot determine a client IP should hand the provider the documented unavailable value, which is the empty string" + "a host that cannot determine a client IP should hand the provider the documented unavailable value, which is the empty string" ); } diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index f02b43bb19..2e359c0eb5 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -22,17 +22,17 @@ use http::HeaderMap; -/// Host-computed client fingerprints that are not carried in request headers. +/// Host-computed client signals that are not carried in request headers. /// /// A host that can compute them supplies an implementation (Fastly exposes the -/// TLS JA4 and HTTP/2 fingerprints). A provider that needs them takes +/// TLS JA4 and HTTP/2 signals). A provider that needs them takes /// `Arc` in its constructor, and on a host that supplies none /// the provider cannot be built and the request stops. pub trait HostSignals: Send + Sync + core::fmt::Debug { - /// The full JA4 TLS fingerprint, or `None` when unavailable. + /// The full JA4 TLS signal, or `None` when unavailable. fn ja4(&self) -> Option<&str>; - /// The raw HTTP/2 SETTINGS fingerprint, or `None` when unavailable. + /// The raw HTTP/2 SETTINGS signal, or `None` when unavailable. fn h2(&self) -> Option<&str>; } diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index afe90b4433..63114a3973 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -196,7 +196,7 @@ Resolve endpoint for client-side Edge Cookie providers. The page posts a value t **Request Body:** the provider's value, opaque to the core. For the `client-fixed` demo this is the fixed known word sent as `text/plain`. -**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the identifier is written to the identity graph first, then the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) together with the `ts-ecr` marker cookie the page script can read, and the status is `200`. When the gate is closed, no client-side provider is configured, no identity graph is available, or the provider produces no identifier, the response is `204` with no cookie. Rejections: `403` for a missing or foreign `Origin`, `415` for a content type other than `text/plain` or `application/json`, `413` for an oversized body, `400` when the minted identifier is outside the identifier bounds, `409` when the request already carries a different identity, and `503` when the identity-graph write fails. Every response the handler builds carries `Cache-Control: no-store`. +**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the identifier is written to the identity graph first, then the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) together with the `ts-ecr` marker cookie the page script can read, and the status is `200`. When the gate is closed, no client-side provider is configured, no identity graph is available, or the provider produces no identifier, the response is `204` with no cookie. Rejections: `403` for a missing or foreign `Origin`, `415` for a content type other than `text/plain` or `application/json`, `413` for an oversized body, `400` when the created identifier is outside the identifier bounds, `409` when the request already carries a different identity, and `503` when the identity-graph write fails. Every response the handler builds carries `Cache-Control: no-store`. --- @@ -610,7 +610,7 @@ The examples below use fictional IDs and values only. ### GET /\_ts/admin/ec/`{id}` -Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format, with or without the `hmac~` provider-code prefix a minted identifier carries. The bare route uses the request's `ts-ec` cookie. +Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format, with or without the `hmac~` provider-code prefix a created identifier carries. The bare route uses the request's `ts-ec` cookie. This lookup is implemented only by the Fastly adapter because the identity graph is stored in Fastly KV. Other adapters return `501 Not Implemented`. diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index eef24ed0e5..826c13ac84 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -112,21 +112,21 @@ The `ec_identity_store` KV store is the only EC lifecycle store. It holds identi The Edge Cookie identifier comes from a configurable provider, selected by `[ec] provider`. A provider is one of two types, and the permission gate above applies to both. The two reach the **same outcome** (a `ts-ec` cookie set and carried on every later request) by **different routes**. -- **Server-side** (for example the built-in HMAC provider, or the built-in `host-signals` provider that mints from the host's TLS JA4 and HTTP/2 fingerprints on a host that supplies them). The provider derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved. -- **Client-side** (for example the `client-fixed` demo). The provider cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the provider's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The provider mints from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. +- **Server-side** (for example the built-in HMAC provider, or the built-in `host-signals` provider that creates the identifier from the host's TLS JA4 and HTTP/2 signals on a host that supplies them). The provider derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved. +- **Client-side** (for example the `client-fixed` demo). The provider cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the provider's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The provider creates the identifier from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. ```mermaid flowchart TD Start(["Page request, no Edge Cookie"]) --> Type{"Provider type"} - Type -->|"Server-side (e.g. HMAC)"| SGen["generate() mints at the edge from request data"] + Type -->|"Server-side (e.g. HMAC)"| SGen["generate() creates the identifier at the edge from request data"] SGen --> SSet["Page response sets ts-ec"] Type -->|"Client-side (e.g. client-fixed)"| CDefer["generate() defers, returns no identifier"] CDefer --> CPage["Page response, no cookie, delivers the provider JS"] CPage --> CBox[["Provider JS (black box): runs in the browser and does its work"]] CBox --> CPost["JS posts the result to POST /_ts/api/v1/ec/resolve"] - CPost --> CResolve["resolve_from_client() verifies and mints"] + CPost --> CResolve["resolve_from_client() verifies and creates"] CResolve --> CSet["Resolve response sets ts-ec"] SSet --> Same(["Same outcome: ts-ec set, carried on every later request"]) @@ -138,15 +138,15 @@ The two types differ only in route and in the methods they use: | Feature | Server-side | Client-side | | -------------------- | ------------------------- | ---------------------------------------------- | | Example | HMAC (`hmac`) | `client-fixed` (demo) | -| Mints in | `generate()`, at the edge | `resolve_from_client()`, from the posted value | +| Created in | `generate()`, at the edge | `resolve_from_client()`, from the posted value | | `generate()` returns | the identifier | no identifier (defers) | | Client JavaScript | none | the provider JS (black box), which posts back | | Endpoint | none | `POST /_ts/api/v1/ec/resolve` | | Cookie set on | the page response | the resolve response | -The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A minted identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-minted one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back; a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client-fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. +The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back; a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client-fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. -Because the posted value comes from the browser, **verification is the provider's responsibility**. A client-side provider must verify the payload (for example a signature) before minting, or a client could forge an Edge Cookie. The endpoint itself is provider-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the provider, and writes the cookie. +Because the posted value comes from the browser, **verification is the provider's responsibility**. A client-side provider must verify the payload (for example a signature) before creating the identifier, or a client could forge an Edge Cookie. The endpoint itself is provider-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the provider, and writes the cookie. A built-in `client-fixed` provider demonstrates the client-side type end to end with no vendor coupling. Client and server share one fixed, known word. When no Edge Cookie is present, the page script (shipped in the tsjs bundle when that provider is selected) posts that word, the server verifies it matches, and on a match sets it as the Edge Cookie. The value is verifiable because it is a known constant, which is the point of the demo. It is useless in production, because a fixed value is not an identity, so it is for demonstration and testing only. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 935d11b58c..7d82c2d69e 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -110,8 +110,8 @@ pull_sync_concurrency = 3 # [ec.providers.hmac] # passphrase = "replace-with-32-plus-byte-random-secret" -# Built-in host-signal provider block. Mints the identifier from the host's TLS -# JA4 and HTTP/2 fingerprints instead of the client IP, so it needs a host that +# Built-in host-signal provider block. Creates the identifier from the host's TLS +# JA4 and HTTP/2 signals instead of the client IP, so it needs a host that # supplies those signals (Fastly does). Uncomment it together with # `provider = "host-signals"` above, and give it its own secret on the same # terms as the hmac block. @@ -141,10 +141,10 @@ pull_sync_concurrency = 3 # Device-detection provider. Selects how a request is classified into device # signals (browser/bot gating). Default "builtin" classifies from the User-Agent # alone and makes no host call, so a default deployment stays host-neutral. The -# opt-in "fastly" provider adds the host's TLS/H2 fingerprints to the gate. +# opt-in "fastly" provider adds the host's TLS/H2 signals to the gate. # Override at deployment with TRUSTED_SERVER__device__provider. # [device] -# provider = "builtin" # or "fastly" to add TLS/H2 fingerprint evidence +# provider = "builtin" # or "fastly" to add TLS/H2 signal evidence [geo] # Geo / IP intelligence provider. No provider is the default: Trusted Server From bcc9a5b219580b461b83e5a677840bfbda56c1a2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 1 Sep 2026 22:29:07 +0100 Subject: [PATCH 07/19] Drop the retired default_country from the configuration guide The permission baseline and consent handling for a request the geo provider leaves unmatched come from the top of the permissions.yaml rules tree, so the [geo] reference no longer documents a default_country field. The deployer states the policy in that file, and the software encodes no jurisdiction's law. --- docs/guide/configuration.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c608a8b5c2..fe5ee38223 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -623,11 +623,10 @@ Selects how a client IP is resolved into geolocation (country, region, coordinat ### `[geo]` -| Field | Type | Required | Description | -| ---------------------------- | -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `provider` | String or null | No | Key of the geo provider. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | -| `default_country` | String | Yes | Country (`US`) or country/region (`US/CA`) whose `permissions.yaml` rule applies when geo returns no country, or a country/region with no rule. Required, so there is always a default permission set and startup fails when unset. Validated at startup. | -| `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request is treated as `default_country`. A deployment that runs an Edge Cookie provider without a geo provider must set this to `true`, acknowledging single-jurisdiction operation; startup fails otherwise. Not needed when a geo provider is selected or no Edge Cookie provider is configured. | +| Field | Type | Required | Description | +| ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Key of the geo provider. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | +| `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie provider without a geo provider acknowledges that by setting this to `true`. | No provider is the default, so a default deployment is not tied to any host geo service. Selecting an unknown provider key fails at startup. A failed geo lookup at request time does not fall back to `default_country`; it resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. @@ -636,7 +635,6 @@ No provider is the default, so a default deployment is not tied to any host geo ```toml [geo] provider = "platform" -default_country = "US" ``` **Environment Override**: From 158c1295da4e642b0363c2571d83cd791506eb7f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 20:35:38 +0100 Subject: [PATCH 08/19] Correct the configuration guide's permissions file location The paragraph still placed the permissions file at the repository root and referenced the retired default_country. It now points at the config/permissions sample and the top-of-tree fallback. --- docs/guide/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index fe5ee38223..554b1f099e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -649,7 +649,7 @@ A provider advertises the technical permissions its data use requires, and Trust ### Country and region rules (`permissions.yaml`) -The country and region permission rules are defined in a human-editable `permissions.yaml` at the repository root, compiled into the build (not loaded at runtime). Edit that file and rebuild to change the policy. There is no `[permissions]` block in `trusted-server.toml`. It defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule uses the required `[geo] default_country` set in `trusted-server.toml`. See the [Permission Model](/guide/permission-model) for the schema and the shipped defaults. +The country and region permission rules are defined in a human-editable permissions YAML document, compiled into the build (not loaded at runtime). The repository sample is `config/permissions/vanilla.yaml`. Edit or replace the compiled-in file and rebuild to change the policy. There is no `[permissions]` block in `trusted-server.toml`. It defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule resolves at the top of the rules tree. See the [Permission Model](/guide/permission-model) for the schema and the repository sample. ## Response Headers From 3d041838f1254082d42524164f670f42a4d58e20 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 13:02:42 +0100 Subject: [PATCH 09/19] Say the host-signal provider also hashes the client IP The example configuration said the host-signal provider creates the identifier from the host's TLS JA4 and HTTP/2 signals instead of the client IP. HostSignalProvider::generate hashes the JA4 and HTTP/2 signals together with the client IP, as the provider's own documentation says, so the comment now says so. --- trusted-server.example.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 621595514d..f545cb54ab 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -112,7 +112,7 @@ pull_sync_concurrency = 3 # passphrase = "ec_passphrase" # Built-in host-signal provider block. Creates the identifier from the host's TLS -# JA4 and HTTP/2 signals instead of the client IP, so it needs a host that +# JA4 and HTTP/2 signals together with the client IP, so it needs a host that # supplies those signals (Fastly does). Uncomment it together with # `provider = "host-signals"` above, and give it its own secret on the same # terms as the hmac block. From 8b7cd94a83d53e6ab89ff04cffd5247a8e5517a7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 13:02:43 +0100 Subject: [PATCH 10/19] Stop naming the retired default_country in the geo guide The configuration guide said a failed geo lookup at request time does not fall back to default_country, a setting that no longer exists. The sentence now says what a failed lookup does without naming it. --- docs/guide/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 26d2bb18de..b5a36c6f49 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -699,7 +699,7 @@ Selects how a client IP is resolved into geolocation (country, region, coordinat | `provider` | String or null | No | Key of the geo provider. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | | `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie provider without a geo provider acknowledges that by setting this to `true`. | -No provider is the default, so a default deployment is not tied to any host geo service. Selecting an unknown provider key fails at startup. A failed geo lookup at request time does not fall back to `default_country`; it resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. +No provider is the default, so a default deployment is not tied to any host geo service. Selecting an unknown provider key fails at startup. A failed geo lookup at request time resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. **Example**: From d11895b8e0d95117a4747393c492b109033fd45c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 15 Sep 2026 17:59:12 +0100 Subject: [PATCH 11/19] Join four clauses in the Edge Cookie guides with words rather than marks Four sentences in the guides joined their clauses with a semicolon, and one table row grew past its column. They now read as one sentence each, and the table is realigned, so the pages follow the house style the rest of the guide follows. --- docs/guide/configuration.md | 16 ++++++++-------- docs/guide/ec-setup-guide.md | 2 +- docs/guide/edge-cookies.md | 2 +- docs/guide/key-rotation.md | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 6479dc64a1..75f312d000 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -581,15 +581,15 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ### `[ec]` -| Field | Type | Required | Description | -| ------------------------- | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Field | Type | Required | Description | +| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider` | String or null | No | Name of the active Edge Cookie provider: `"hmac"` (built-in), `"host_signals"` (opt-in), or `"none"` (explicitly stateless). Omit to run statelessly with no Edge Cookie. The `"client_fixed"` demo needs the `client-fixed-demo` build feature | | `resolve_allowed_origins` | Array | No | Extra exact origins allowed to POST the client resolve endpoint, beyond `https://{publisher.domain}` | -| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | -| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | -| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | -| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | -| `partners` | Array | No | Static partner registry entries | +| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | +| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | +| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | +| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | +| `partners` | Array | No | Static partner registry entries | Each provider that has settings is configured in its own `[ec.]` block, and the `provider` selector names which block is active. A block may set `implementation = ""` to say which provider it configures, which makes the block name a label of your choosing, so `provider = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in provider under a name that means something to your deployment. Provider names and implementation ids are `snake_case`. @@ -2227,7 +2227,7 @@ Configuration is validated at startup: **EC Validation**: -- `provider`, when set, is `snake_case` and has the `[ec.]` block its implementation needs, and no unselected block is left configured; otherwise startup fails +- `provider`, when set, is `snake_case` and has the `[ec.]` block its implementation needs, and no unselected block is left configured, or startup fails - The `hmac.passphrase` key name is non-empty at push time - The resolved passphrase is at least 32 bytes at runtime - Known placeholder values are rejected after resolution diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index 5333480fc1..2b11440388 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -47,7 +47,7 @@ do not call either API may omit it. Provision high-entropy values under Required behavior assumptions: -- `provider = "hmac"` selects the built-in HMAC provider; its `passphrase` lives under `[ec.hmac]` +- `provider = "hmac"` selects the built-in HMAC provider, and its `passphrase` lives under `[ec.hmac]` - The value stored under `ec_passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters - `ec_store` is linked to the active Fastly service version - `ec_store` is the only KV-backed EC lifecycle store; it contains identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 8787179e3a..3e3f2fac19 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -144,7 +144,7 @@ The two types differ only in route and in the methods they use: | Endpoint | none | `POST /_ts/api/v1/ec/resolve` | | Cookie set on | the page response | the resolve response | -The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back; a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client_fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. +The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back, and a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client_fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. Because the posted value comes from the browser, **verification is the provider's responsibility**. A client-side provider must verify the payload (for example a signature) before creating the identifier, or a client could forge an Edge Cookie. The endpoint itself is provider-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the provider, and writes the cookie. diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md index 081dd5c0ed..cde347612b 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -28,7 +28,7 @@ Key rotation is the process of generating new signing keys and transitioning fro ## Edge Cookie HMAC Passphrase -The Edge Cookie `ec.hmac.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters; shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph. +The Edge Cookie `ec.hmac.passphrase` is long-lived HMAC-SHA256 keying material used to derive visitor EC IDs. Use a high-entropy random value of at least 32 characters, because shorter values are rejected at settings validation. Rotating this passphrase changes derived EC IDs and requires rebuilding or allowing expiry of the existing EC identity graph. ## Prerequisites From caccf8df80b4a6c8d900231c234196cc06349634 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 6 Oct 2026 21:42:15 +0100 Subject: [PATCH 12/19] The documentation set says module where it said provider What an operator selects to supply a capability is a module, and provider keeps the one meaning it has on main, an auction provider instance. This renames the words this branch adds on top of the client resolve path, which already says module, and only those words: every provider word was compared with upstream main, and a word that exists there is untouched, as is the icu_provider crate the permissions inspector's lock file names. - The environment overrides the configuration guide and the error reference show read TRUSTED_SERVER__EC__MODULE, TRUSTED_SERVER__DEVICE__MODULE and TRUSTED_SERVER__GEO__MODULE, and the selectors in every example read module. - The configuration, Edge Cookie, setup, error reference and Fastly guides, the example settings, the evidence module's documentation and the no-client-IP test's message say module wherever they meant an Edge Cookie, device or geo module. --- crates/trusted-server-core/src/ec/mod.rs | 4 +- crates/trusted-server-core/src/evidence.rs | 8 +-- docs/guide/configuration.md | 82 +++++++++++----------- docs/guide/ec-setup-guide.md | 6 +- docs/guide/edge-cookies.md | 30 ++++---- docs/guide/error-reference.md | 8 +-- docs/guide/fastly.md | 2 +- trusted-server.example.toml | 4 +- 8 files changed, 72 insertions(+), 72 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index bd3ff65bac..1b8c3296ab 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1825,13 +1825,13 @@ pub(crate) mod tests { "the identifier should be committed with no client IP available" ); assert_eq!( - provider + module .seen_client_ip .lock() .expect("should lock the seen client IP") .clone(), Some(String::new()), - "a host that cannot determine a client IP should hand the provider the documented unavailable value, which is the empty string" + "a host that cannot determine a client IP should hand the module the documented unavailable value, which is the empty string" ); } diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 42de1840f3..826735da56 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -16,8 +16,8 @@ //! //! [`RequestInfo`] describes what a request carries, not what code in this //! repository happens to read today, so it carries the whole of it whether or -//! not anything here reads it yet. What a provider may see is not the control. -//! What a provider may do with what it sees is the control, and that is the +//! not anything here reads it yet. What a module may see is not the control. +//! What a module may do with what it sees is the control, and that is the //! permission model. use http::HeaderMap; @@ -25,9 +25,9 @@ use http::HeaderMap; /// Host-computed client signals that are not carried in request headers. /// /// A host that can compute them supplies an implementation (Fastly exposes the -/// TLS JA4 and HTTP/2 signals). A provider that needs them takes +/// TLS JA4 and HTTP/2 signals). A module that needs them takes /// `Arc` in its constructor, and on a host that supplies none -/// the provider cannot be built and the request stops. +/// the module cannot be built and the request stops. pub trait HostSignals: Send + Sync + core::fmt::Debug { /// The full JA4 TLS signal, or `None` when unavailable. fn ja4(&self) -> Option<&str>; diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b6d65d1196..f0e0793c82 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -26,7 +26,7 @@ origin_url = "https://origin.publisher.com" proxy_secret = "publisher_proxy_secret" [ec] -provider = "hmac" +module = "hmac" [ec.hmac] passphrase = "ec_passphrase" @@ -44,7 +44,7 @@ export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com # Secret overrides, when needed, are key names—not secret values. export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret -export TRUSTED_SERVER__EC__PROVIDER=hmac +export TRUSTED_SERVER__EC__MODULE=hmac export TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase # Replace the rejected placeholder values in trusted-server.toml, then validate. @@ -204,9 +204,9 @@ fail and the service will return its startup-error response. | `[consent]` | Consent interpretation, forwarding, and conflict resolution | | `[creative_opportunities]` | Server-side page ad opportunities and templates | | `[debug]` | Explicit non-production diagnostics | -| `[device]` | Device classification provider selection | +| `[device]` | Device classification module selection | | `[ec]` | Edge Cookie identity, persistence, and partner sync | -| `[geo]` | Geolocation provider selection | +| `[geo]` | Geolocation module selection | | `[[handlers]]` | Ordered HTTP Basic-auth rules | | `[image_optimizer]` | Reusable Fastly Image Optimizer profiles | | `[integrations.*]` | Typed partner and browser integration settings | @@ -233,7 +233,7 @@ origin_url = "https://origin.publisher.com" proxy_secret = "publisher_proxy_secret" [ec] -provider = "hmac" +module = "hmac" [ec.hmac] passphrase = "ec_passphrase" @@ -646,23 +646,23 @@ Legacy consent-store records are not read or migrated into `ec.ec_store`. Their ### `[ec]` -| Field | Type | Required | Description | -| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `provider` | String or null | No | Name of the active Edge Cookie provider: `"hmac"` (built-in), `"host_signals"` (opt-in), or `"none"` (explicitly stateless). Omit to run statelessly with no Edge Cookie. The `"client_fixed"` demo needs the `client-fixed-demo` build feature | -| `resolve_allowed_origins` | Array | No | Extra exact origins allowed to POST the client resolve endpoint, beyond `https://{publisher.domain}` | -| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | -| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | -| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | -| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | -| `partners` | Array | No | Static partner registry entries | +| Field | Type | Required | Description | +| ------------------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `module` | String or null | No | Name of the active Edge Cookie module: `"hmac"` (built-in), `"host_signals"` (opt-in), or `"none"` (explicitly stateless). Omit to run statelessly with no Edge Cookie. The `"client_fixed"` demo needs the `client-fixed-demo` build feature | +| `resolve_allowed_origins` | Array | No | Extra exact origins allowed to POST the client resolve endpoint, beyond `https://{publisher.domain}` | +| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | +| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | +| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | +| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | +| `partners` | Array | No | Static partner registry entries | -Each provider that has settings is configured in its own `[ec.]` block, and the `provider` selector names which block is active. A block may set `implementation = ""` to say which provider it configures, which makes the block name a label of your choosing, so `provider = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in provider under a name that means something to your deployment. Provider names and implementation ids are `snake_case`. +Each module that has settings is configured in its own `[ec.]` block, and the `module` selector names which block is active. A block may set `implementation = ""` to say which module it configures, which makes the block name a label of your choosing, so `module = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in module under a name that means something to your deployment. Module names and implementation ids are `snake_case`. -A provider has a block only when it has settings of its own. Both providers that derive an identifier at the edge take a passphrase, so selecting `hmac` or `host_signals` without its block fails at startup, while the `client_fixed` demonstration provider needs no block at all. A block the selector does not name also fails at startup, so a stale block cannot sit unnoticed. +A module has a block only when it has settings of its own. Both modules that derive an identifier at the edge take a passphrase, so selecting `hmac` or `host_signals` without its block fails at startup, while the `client_fixed` demonstration module needs no block at all. A block the selector does not name also fails at startup, so a stale block cannot sit unnoticed. ### `[ec.hmac]` -The built-in HMAC-over-client-IP provider, keyed `hmac`. +The built-in HMAC-over-client-IP module, keyed `hmac`. `passphrase` is a key name in `trusted_server_secrets`; the resolved value must be at least 32 bytes. Keep it stable to preserve EC identifier continuity. @@ -684,7 +684,7 @@ outbound pull sync, but cannot authenticate to those inbound APIs. ```toml [ec] -provider = "hmac" +module = "hmac" ec_store = "ec_identity_store" [ec.hmac] @@ -701,24 +701,24 @@ bidstream_enabled = true **Environment Override**: ```bash -TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__MODULE=hmac TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase TRUSTED_SERVER__EC__EC_STORE=ec_identity_store ``` -These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environment values into the published configuration (for example test harnesses building an app-config blob). The running server reads its settings from the platform config store, so provider selection changes take effect when a new configuration is pushed, not per request. +These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environment values into the published configuration (for example test harnesses building an app-config blob). The running server reads its settings from the platform config store, so module selection changes take effect when a new configuration is pushed, not per request. ### Field Details -#### `provider` +#### `module` -**Purpose**: Names the active Edge Cookie provider by its key. Omit to run statelessly with no Edge Cookie. +**Purpose**: Names the active Edge Cookie module by its key. Omit to run statelessly with no Edge Cookie. **Validation**: Application startup fails if the name is not `snake_case`, if it names a key the `[ec]` section reads as its own setting, if the selected implementation needs an `[ec.]` block it has no block for, or if a block the selector does not name is configured. #### `hmac.passphrase` -**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation, read when `provider = "hmac"`. +**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation, read when `module = "hmac"`. **Security**: @@ -733,58 +733,58 @@ These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environ ## Device Configuration -Selects how a request is classified into the coarse device signals the Edge Cookie bot gate uses, mirroring the Edge Cookie provider selection. These signals serve identifier gating and bot detection, not bid enrichment. +Selects how a request is classified into the coarse device signals the Edge Cookie bot gate uses, mirroring the Edge Cookie module selection. These signals serve identifier gating and bot detection, not bid enrichment. ### `[device]` -| Field | Type | Required | Description | -| ---------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `provider` | String or null | No | Key of the device-detection provider. Defaults to `builtin` (User-Agent only, no host-specific call). Set `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers | +| Field | Type | Required | Description | +| -------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `module` | String or null | No | Key of the device-detection module. Defaults to `builtin` (User-Agent only, no host-specific call). Set `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers | -The default `builtin` provider classifies from the User-Agent alone and makes no host-specific call, so the default path stays host-neutral. Selecting an unknown provider key fails at startup. +The default `builtin` module classifies from the User-Agent alone and makes no host-specific call, so the default path stays host-neutral. Selecting an unknown module key fails at startup. **Example**: ```toml [device] -provider = "builtin" # or "fastly" to add TLS and HTTP/2 evidence +module = "builtin" # or "fastly" to add TLS and HTTP/2 evidence ``` **Environment Override**: ```bash -TRUSTED_SERVER__DEVICE__PROVIDER=builtin +TRUSTED_SERVER__DEVICE__MODULE=builtin ``` ## Geo Configuration -Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie provider selection. The resolved country also feeds the [permission model](/guide/permission-model). +Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie module selection. The resolved country also feeds the [permission model](/guide/permission-model). ### `[geo]` -| Field | Type | Required | Description | -| ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `provider` | String or null | No | Key of the geo provider. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | -| `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie provider without a geo provider acknowledges that by setting this to `true`. | +| Field | Type | Required | Description | +| ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `module` | String or null | No | Key of the geo module. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | +| `assume_single_jurisdiction` | Boolean | See description | With no geo module, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie module without a geo module acknowledges that by setting this to `true`. | -No provider is the default, so a default deployment is not tied to any host geo service. Selecting an unknown provider key fails at startup. A failed geo lookup at request time resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. +No module is the default, so a default deployment is not tied to any host geo service. Selecting an unknown module key fails at startup. A failed geo lookup at request time resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. **Example**: ```toml [geo] -provider = "platform" +module = "platform" ``` **Environment Override**: ```bash -TRUSTED_SERVER__GEO__PROVIDER=platform +TRUSTED_SERVER__GEO__MODULE=platform ``` -## Provider Permissions +## Module Permissions -A provider advertises the technical permissions its data use requires, and Trusted Server runs the provider only when every required permission is set. This separates legal policy from the core, so the deployer brings the policy that decides how permissions are established. See the [Permission Model](/guide/permission-model) for the concept, the permission vocabulary, and how a request resolves. +A module advertises the technical permissions its data use requires, and Trusted Server runs the module only when every required permission is set. This separates legal policy from the core, so the deployer brings the policy that decides how permissions are established. See the [Permission Model](/guide/permission-model) for the concept, the permission vocabulary, and how a request resolves. ### Country and region rules (`permissions.yaml`) @@ -3038,7 +3038,7 @@ Configuration is validated at startup: **EC Validation**: -- `provider`, when set, is `snake_case` and has the `[ec.]` block its implementation needs, and no unselected block is left configured, or startup fails +- `module`, when set, is `snake_case` and has the `[ec.]` block its implementation needs, and no unselected block is left configured, or startup fails - The `hmac.passphrase` key name is non-empty at push time - The resolved passphrase is at least 32 bytes at runtime - Known placeholder values are rejected after resolution diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index 7d60d19da4..d67a84ca93 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -25,7 +25,7 @@ The `replace-with-*` values below are intentionally rejected placeholders: ```toml [ec] -provider = "hmac" +module = "hmac" ec_store = "ec_identity_store" [ec.hmac] @@ -47,7 +47,7 @@ do not call either API may omit it. Provision high-entropy values under Required behavior assumptions: -- `provider = "hmac"` selects the built-in HMAC provider, and its `passphrase` lives under `[ec.hmac]` +- `module = "hmac"` selects the built-in HMAC module, and its `passphrase` lives under `[ec.hmac]` - The value stored under `ec_passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters - `ec_store` is linked to the active Fastly service version - `ec_store` is the only KV-backed EC lifecycle store; it contains identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones @@ -103,7 +103,7 @@ curl -si "${TS_BASE_URL}/" \ Look for: -- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the provider code; pre-series cookies without it still resolve) +- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the module code; pre-series cookies without it still resolve) ## 5) Batch Sync (S2S) diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 2eb72b3a38..add979aab5 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -10,7 +10,7 @@ permit EC use. Trusted Server is technology. It is neutral on policy. The Edge Cookie gives the deployer a cookie slot and configuration over the surrounding attributes. The deployer determines the policy posture based on the laws and contractual arrangements that apply to their deployment. Privacy outcomes follow from that configuration, not from the cookie mechanism itself. -An Edge Cookie (EC) is a first-party identifier that the built-in provider derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server surfaces the current EC ID via response headers and a first-party cookie. For the exact header and cookie names, see the [API Reference](/guide/api-reference). +An Edge Cookie (EC) is a first-party identifier that the built-in module derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server surfaces the current EC ID via response headers and a first-party cookie. For the exact header and cookie names, see the [API Reference](/guide/api-reference). For full operational onboarding (partner configuration, batch sync, identify, and auction verification), use the [EC Setup Guide](/guide/ec-setup-guide). @@ -88,11 +88,11 @@ When the required permissions cannot be established for the current request (for ## Permission Gating -EC creation is gated through the [permission model](/guide/permission-model), not by a jurisdiction rule baked into the core. The Edge Cookie provider advertises the permissions its data use requires, and Trusted Server creates an Edge Cookie only when every required permission is set. The built-in HMAC provider requires `necessary.operations.storage` (TCF Purpose 1), because the `Set-Cookie` operation stores information on the device. +EC creation is gated through the [permission model](/guide/permission-model), not by a jurisdiction rule baked into the core. The Edge Cookie module advertises the permissions its data use requires, and Trusted Server creates an Edge Cookie only when every required permission is set. The built-in HMAC module requires `necessary.operations.storage` (TCF Purpose 1), because the `Set-Cookie` operation stores information on the device. The Edge Cookie code never reads consent. It checks only whether the required **permission** is set. Consent is one of the sources that _set_ a permission, not something the gate reads directly, so the Edge Cookie logic does not change when a consent framework changes. Two sources combine for each request: -- **A country and region baseline.** The country, and an optional region such as a US state, that the geo provider returns. A region rule takes precedence over its country, and when no country is identified, or the country/region has no rule, the configured default country applies. +- **A country and region baseline.** The country, and an optional region such as a US state, that the geo module returns. A region rule takes precedence over its country, and when no country is identified, or the country/region has no rule, the configured default country applies. - **Consent and privacy signals.** TCF, GPP, and GPC (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`) decoded from the request and mapped onto permissions as a **grant or a revoke** on top of that baseline. There is no separate consent KV fallback. Today only `necessary.operations.storage` is resolved this way: its country and region baseline is adjusted by the incoming TCF signal, and the Edge Cookie is created only when the result is set. With no configured default country, an unknown country sets nothing without a signal, so the cookie is not created unless a signal grants the permission. The core encodes no jurisdiction's law. The deployer brings the policy, and the per-country and per-region rules are configuration rather than core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. @@ -101,30 +101,30 @@ Today only `necessary.operations.storage` is resolved this way: its country and flowchart TD Start[Resolve country and region] --> Baseline[Country or region rule,
else the default country] Baseline --> Signals[Apply consent/privacy signals
as a grant or revoke] - Signals --> Check{Provider's required
permissions all set?} + Signals --> Check{Module's required
permissions all set?} Check -- "Yes" --> Allow([Create EC]) Check -- "No" --> Deny([No EC]) ``` The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, withdrawal tombstones, and completion markers that prevent stale point-read misses from rewriting completed tombstones. A marker key records the original tombstone's validity bound and is ignored at or after that time, even if its KV row has not expired yet. If the clock is unusable, the marker is ignored and withdrawal falls back to a strong root-existence check; only a confirmed existing root can be written. This prevents a stale marker from suppressing withdrawal when an expired EC key is created again. With a healthy store, repeated withdrawal of an already tombstoned EC ID leaves the row unchanged instead of refreshing its 24-hour TTL. Permission resolution for each request is based on the live request signals listed above. -## Provider Types: Server-Side and Client-Side +## Module Types: Server-Side and Client-Side -The Edge Cookie identifier comes from a configurable provider, selected by `[ec] provider`. A provider is one of two types, and the permission gate above applies to both. The two reach the **same outcome** (a `ts-ec` cookie set and carried on every later request) by **different routes**. +The Edge Cookie identifier comes from a configurable module, selected by `[ec] module`. A module is one of two types, and the permission gate above applies to both. The two reach the **same outcome** (a `ts-ec` cookie set and carried on every later request) by **different routes**. -- **Server-side** (for example the built-in HMAC provider, or the built-in `host_signals` provider that creates the identifier from the host's TLS JA4 and HTTP/2 signals on a host that supplies them). The provider derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved. -- **Client-side** (for example the `client_fixed` demo). The provider cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the provider's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The provider creates the identifier from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. +- **Server-side** (for example the built-in HMAC module, or the built-in `host_signals` module that creates the identifier from the host's TLS JA4 and HTTP/2 signals on a host that supplies them). The module derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved. +- **Client-side** (for example the `client_fixed` demo). The module cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the module's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The module creates the identifier from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. ```mermaid flowchart TD - Start(["Page request, no Edge Cookie"]) --> Type{"Provider type"} + Start(["Page request, no Edge Cookie"]) --> Type{"Module type"} Type -->|"Server-side (e.g. HMAC)"| SGen["generate() creates the identifier at the edge from request data"] SGen --> SSet["Page response sets ts-ec"] Type -->|"Client-side (e.g. client_fixed)"| CDefer["generate() defers, returns no identifier"] - CDefer --> CPage["Page response, no cookie, delivers the provider JS"] - CPage --> CBox[["Provider JS (black box): runs in the browser and does its work"]] + CDefer --> CPage["Page response, no cookie, delivers the module JS"] + CPage --> CBox[["Module JS (black box): runs in the browser and does its work"]] CBox --> CPost["JS posts the result to POST /_ts/api/v1/ec/resolve"] CPost --> CResolve["resolve_from_client() verifies and creates"] CResolve --> CSet["Resolve response sets ts-ec"] @@ -140,15 +140,15 @@ The two types differ only in route and in the methods they use: | Example | HMAC (`hmac`) | `client_fixed` (demo) | | Created in | `generate()`, at the edge | `resolve_from_client()`, from the posted value | | `generate()` returns | the identifier | no identifier (defers) | -| Client JavaScript | none | the provider JS (black box), which posts back | +| Client JavaScript | none | the module JS (black box), which posts back | | Endpoint | none | `POST /_ts/api/v1/ec/resolve` | | Cookie set on | the page response | the resolve response | -The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back, and a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client_fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. +The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back, and a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client_fixed` demonstration module is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. -Because the posted value comes from the browser, **verification is the provider's responsibility**. A client-side provider must verify the payload (for example a signature) before creating the identifier, or a client could forge an Edge Cookie. The endpoint itself is provider-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the provider, and writes the cookie. +Because the posted value comes from the browser, **verification is the module's responsibility**. A client-side module must verify the payload (for example a signature) before creating the identifier, or a client could forge an Edge Cookie. The endpoint itself is module-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the module, and writes the cookie. -A built-in `client_fixed` provider demonstrates the client-side type end to end with no vendor coupling. Client and server share one fixed, known word. When no Edge Cookie is present, the page script (shipped in the tsjs bundle when that provider is selected) posts that word, the server verifies it matches, and on a match sets it as the Edge Cookie. The value is verifiable because it is a known constant, which is the point of the demo. It is useless in production, because a fixed value is not an identity, so it is for demonstration and testing only. +A built-in `client_fixed` module demonstrates the client-side type end to end with no vendor coupling. Client and server share one fixed, known word. When no Edge Cookie is present, the page script (shipped in the tsjs bundle when that module is selected) posts that word, the server verifies it matches, and on a match sets it as the Edge Cookie. The value is verifiable because it is a known constant, which is the point of the demo. It is useless in production, because a fixed value is not an identity, so it is for demonstration and testing only. ## Partner Sync Channels diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index 5a786f3ba8..4a29464999 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -75,7 +75,7 @@ TOML file. - `publisher.domain` - `publisher.origin_url` - `publisher.proxy_secret` -- `ec.hmac.passphrase` (when `ec.provider = "hmac"`) +- `ec.hmac.passphrase` (when `ec.module = "hmac"`) --- @@ -149,11 +149,11 @@ Failed to generate EC ID: HMAC error **Solution:** -1. Ensure the `hmac` provider is selected and its `passphrase` names a secret-store entry in `trusted-server.toml`: +1. Ensure the `hmac` module is selected and its `passphrase` names a secret-store entry in `trusted-server.toml`: ```toml [ec] -provider = "hmac" +module = "hmac" [ec.hmac] passphrase = "ec_passphrase" @@ -163,7 +163,7 @@ passphrase = "ec_passphrase" passphrase value: ```bash -TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__MODULE=hmac TRUSTED_SERVER__EC__HMAC__PASSPHRASE=ec_passphrase ``` diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 4a7e00e92f..aa1f402d4b 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -336,7 +336,7 @@ Configure the secret-store key name in `trusted-server.toml`: ```toml [ec] -provider = "hmac" +module = "hmac" ec_store = "ec_identity_store" [ec.hmac] diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 6e16fb7a4a..a62d1411ab 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -124,10 +124,10 @@ pull_sync_concurrency = 3 # implementation = "hmac" # passphrase = "ec_passphrase" -# Built-in host-signal provider block. Creates the identifier from the host's TLS +# Built-in host-signal module block. Creates the identifier from the host's TLS # JA4 and HTTP/2 signals together with the client IP, so it needs a host that # supplies those signals (Fastly does). Uncomment it together with -# `provider = "host_signals"` above, and give it its own secret on the same +# `module = "host_signals"` above, and give it its own secret on the same # terms as the hmac block. # [ec.host_signals] # passphrase = "ec_host_signals_passphrase" From 93af3ea2bc462fbed1a5bbb4959855845a99d1a8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 7 Oct 2026 07:32:11 +0100 Subject: [PATCH 13/19] The configuration guide states the module name rule A name written in `[ec] module`, an `[ec.]` block or an `implementation` line is parts joined by `.`, each of lower case letters, digits, `_` or `-`, in place of snake_case, and a module from a crate may be written in full, as `edgecookie.`, or with that type folder left off. The sections table names `[permission-signal]`. --- docs/guide/configuration.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f0e0793c82..0ed8e1cafd 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -210,7 +210,7 @@ fail and the service will return its startup-error response. | `[[handlers]]` | Ordered HTTP Basic-auth rules | | `[image_optimizer]` | Reusable Fastly Image Optimizer profiles | | `[integrations.*]` | Typed partner and browser integration settings | -| `[permission_signal]` | Permission signal schemes and the order they run in | +| `[permission-signal]` | Permission signal schemes and the order they run in | | `[proxy]` | Proxy allowlist, TLS policy, and asset routes | | `[publisher]` | Publisher domain, origin, and proxy signing key | | `[request_signing]` | Outbound Ed25519 request signing and management-store IDs | @@ -656,7 +656,7 @@ Legacy consent-store records are not read or migrated into `ec.ec_store`. Their | `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | | `partners` | Array | No | Static partner registry entries | -Each module that has settings is configured in its own `[ec.]` block, and the `module` selector names which block is active. A block may set `implementation = ""` to say which module it configures, which makes the block name a label of your choosing, so `module = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in module under a name that means something to your deployment. Module names and implementation ids are `snake_case`. +Each module that has settings is configured in its own `[ec.]` block, and the `module` selector names which block is active. A block may set `implementation = ""` to say which module it configures, which makes the block name a label of your choosing, so `module = "primary"` with `[ec.primary]` holding `implementation = "hmac"` configures the built-in module under a name that means something to your deployment. A module from a crate is named by its folder below `crates/`, so it may be written in full, as `edgecookie.`, or with that type folder left off, and core's own modules such as `hmac` take bare names. A name is parts joined by `.`, each of lower case letters, digits, `_` or `-`. A module has a block only when it has settings of its own. Both modules that derive an identifier at the edge take a passphrase, so selecting `hmac` or `host_signals` without its block fails at startup, while the `client_fixed` demonstration module needs no block at all. A block the selector does not name also fails at startup, so a stale block cannot sit unnoticed. @@ -714,7 +714,7 @@ These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environ **Purpose**: Names the active Edge Cookie module by its key. Omit to run statelessly with no Edge Cookie. -**Validation**: Application startup fails if the name is not `snake_case`, if it names a key the `[ec]` section reads as its own setting, if the selected implementation needs an `[ec.]` block it has no block for, or if a block the selector does not name is configured. +**Validation**: Application startup fails if the name is not a module name (parts joined by `.`, each of lower case letters, digits, `_` or `-`), if it names a key the `[ec]` section reads as its own setting, if the selected implementation needs an `[ec.]` block it has no block for, or if a block the selector does not name is configured. #### `hmac.passphrase` @@ -3038,7 +3038,7 @@ Configuration is validated at startup: **EC Validation**: -- `module`, when set, is `snake_case` and has the `[ec.]` block its implementation needs, and no unselected block is left configured, or startup fails +- `module`, when set, is a module name and has the `[ec.]` block its implementation needs, and no unselected block is left configured, or startup fails - The `hmac.passphrase` key name is non-empty at push time - The resolved passphrase is at least 32 bytes at runtime - Known placeholder values are rejected after resolution From f220687baf21b256f5f83ebd2f4146baf1b86196 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 7 Oct 2026 12:42:50 +0100 Subject: [PATCH 14/19] The Edge Cookie guides describe the permission model as it is The Edge Cookie guide still said that an unknown country falls back to a configured default country, and that only the storage permission is resolved from a TCF signal. Neither is how the permission model works. - When no country is identified, or neither the region nor the country has a rule, the baseline on the top node of the rules tree applies. - Every permission is resolved from that baseline and the signals, each signal read by its own permission signal module in the configured order, with the Model Terms for Marketing preference among them. - A location lookup that fails resolves at the requires-signal floor. The setup guide describes the bare identifier form by what it is, a value with no module prefix, rather than by when it was issued. --- docs/guide/ec-setup-guide.md | 4 ++-- docs/guide/edge-cookies.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index d67a84ca93..a2321309f7 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -103,13 +103,13 @@ curl -si "${TS_BASE_URL}/" \ Look for: -- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the module code; pre-series cookies without it still resolve) +- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the module code, and a bare value with no prefix, as a browser may already hold, still resolves) ## 5) Batch Sync (S2S) Endpoint: `POST /_ts/api/v1/batch-sync` -Important: request field is `ec_id` (the full value as issued, `hmac~{64hex}.{6alnum}`; the bare pre-series form is also accepted). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Within one request, valid mappings are grouped by normalized EC ID and the last valid UID for each group is applied once; unchanged UIDs are accepted without a write. Group outcomes are reported for every original mapping, and infrastructure failures abort the remaining groups. See the [API Reference](/guide/api-reference) for the complete accounting and failure contract. +Important: request field is `ec_id` (the full value as issued, `hmac~{64hex}.{6alnum}`, with the bare form that has no prefix also accepted). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Within one request, valid mappings are grouped by normalized EC ID and the last valid UID for each group is applied once; unchanged UIDs are accepted without a write. Group outcomes are reported for every original mapping, and infrastructure failures abort the remaining groups. See the [API Reference](/guide/api-reference) for the complete accounting and failure contract. ```bash BATCH_UID="${PARTNER_UID}-batch" diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index add979aab5..fab98bed17 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -84,7 +84,7 @@ flowchart TD WasPresent -- "No, just generated" --> NewEc["Ingest Prebid EID cookies
Set ts-ec cookie"] ``` -When the required permissions cannot be established for the current request (for example an unknown country with no configured default, or missing or undecodable consent signals), Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. +When the required permissions are not set for the current request (for example a location lookup that failed, or a baseline that requires a signal no module has answered), Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. ## Permission Gating @@ -92,14 +92,14 @@ EC creation is gated through the [permission model](/guide/permission-model), no The Edge Cookie code never reads consent. It checks only whether the required **permission** is set. Consent is one of the sources that _set_ a permission, not something the gate reads directly, so the Edge Cookie logic does not change when a consent framework changes. Two sources combine for each request: -- **A country and region baseline.** The country, and an optional region such as a US state, that the geo module returns. A region rule takes precedence over its country, and when no country is identified, or the country/region has no rule, the configured default country applies. -- **Consent and privacy signals.** TCF, GPP, and GPC (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`) decoded from the request and mapped onto permissions as a **grant or a revoke** on top of that baseline. There is no separate consent KV fallback. +- **A country and region baseline.** The country, and an optional region such as a US state, that the geo module returns. A region rule takes precedence over its country, and when no country is identified, or neither the region nor the country has a rule, the baseline on the top node of the rules tree applies. +- **Consent and privacy signals.** TCF, GPP, a US Privacy string, GPC and the Model Terms for Marketing preference (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`, `__mtm_pref`), each read by its own [permission signal module](/guide/permission-signals) and mapped onto permissions as a **grant or a revoke** on top of that baseline, in the configured order. There is no separate consent KV fallback. -Today only `necessary.operations.storage` is resolved this way: its country and region baseline is adjusted by the incoming TCF signal, and the Edge Cookie is created only when the result is set. With no configured default country, an unknown country sets nothing without a signal, so the cookie is not created unless a signal grants the permission. The core encodes no jurisdiction's law. The deployer brings the policy, and the per-country and per-region rules are configuration rather than core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. +Every permission is resolved this way, and the Edge Cookie is created only when each permission its module requires is set. A location lookup that fails resolves at the requires-signal floor, so the cookie is not created unless a signal grants the permission. The core encodes no jurisdiction's law. The deployer brings the policy, and the per-country and per-region rules live in the policy file rather than in core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. ```mermaid flowchart TD - Start[Resolve country and region] --> Baseline[Country or region rule,
else the default country] + Start[Resolve country and region] --> Baseline[Country or region rule,
else the top of the rules tree] Baseline --> Signals[Apply consent/privacy signals
as a grant or revoke] Signals --> Check{Module's required
permissions all set?} Check -- "Yes" --> Allow([Create EC]) From d989f588d0b201c1f864bf384c2e08619c2e889c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 15:39:55 +0100 Subject: [PATCH 15/19] The test loader leaves out the build's git version variable Core's build script compiles the git version in by emitting TRUSTED_SERVER__GIT_VERSION as a rustc-env variable, and cargo also sets that variable on the test process. The test-only from_toml_and_env read it as a git_version setting, which Settings refuses as an unknown field, so twelve settings tests failed in the host-target core library tests this branch runs in CI. The loader now builds its environment source from the process variables without that one, and a new test loads settings with the variable set. --- crates/trusted-server-core/src/settings.rs | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0b43ace350..c317844aea 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4002,9 +4002,15 @@ impl Settings { /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields #[cfg(test)] pub fn from_toml_and_env(toml_str: &str) -> Result> { + // The build script compiles the git version in through this variable, + // and cargo sets it on a test process too. It is not a setting. + let variables = std::env::vars() + .filter(|(name, _)| name != "TRUSTED_SERVER__GIT_VERSION") + .collect(); let environment = Environment::default() .prefix(ENVIRONMENT_VARIABLE_PREFIX) - .separator(ENVIRONMENT_VARIABLE_SEPARATOR); + .separator(ENVIRONMENT_VARIABLE_SEPARATOR) + .source(Some(variables)); let toml = File::from_str(toml_str, FileFormat::Toml); let config = Config::builder() @@ -7950,6 +7956,21 @@ source_domain = "partner.example.com" ); } + #[test] + fn from_toml_and_env_loads_while_the_git_version_variable_is_set() { + temp_env::with_var( + format!( + "{}{}GIT_VERSION", + ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR + ), + Some("v1.0.0"), + || { + Settings::from_toml_and_env(&crate_test_settings_str()) + .expect("should load settings while the build's git version variable is set"); + }, + ); + } + #[test] fn test_override_env() { let toml_str = crate_test_settings_str(); From bae9bc839214197f57e7b4e2846f7e51cce80839 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 18 Oct 2026 21:30:25 +0100 Subject: [PATCH 16/19] The Edge Cookie guide says the identifier is kept in the ts-ec cookie No response carries the EC ID in a header of its own. Core reads x-ts-ec from requests, removes it from the requests it forwards and clears it from responses, and a module may not set an x-ts- response header, so the identifier reaches the browser only in the ts-ec cookie, as the API reference says. The sentence now says so, and points to the API reference for the routes that use the cookie. --- docs/guide/edge-cookies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index ea53f521ca..67785b3561 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -10,7 +10,7 @@ permit EC use. Trusted Server is technology. It is neutral on policy. The Edge Cookie gives the deployer a cookie slot and configuration over the surrounding attributes. The deployer determines the policy posture based on the laws and contractual arrangements that apply to their deployment. Privacy outcomes follow from that configuration, not from the cookie mechanism itself. -An Edge Cookie (EC) is a first-party identifier that the built-in module derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server surfaces the current EC ID via response headers and a first-party cookie. For the exact header and cookie names, see the [API Reference](/guide/api-reference). +An Edge Cookie (EC) is a first-party identifier that the built-in module derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server keeps the current EC ID in the first-party `ts-ec` cookie. For the routes that use the cookie, see the [API Reference](/guide/api-reference). For full operational onboarding (partner configuration, batch sync, identify, and auction verification), use the [EC Setup Guide](/guide/ec-setup-guide). From e231669c20c18c58db1cc397b31b4b5bf2dd3d50 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:25 +0000 Subject: [PATCH 17/19] The configuration guide says the policy file is selected when deployed --- docs/guide/configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a775bd892d..8bbf71bfc7 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -802,7 +802,7 @@ A module advertises the technical permissions its data use requires, and Trusted ### Country and region rules (`permissions.yaml`) -The country and region permission rules are defined in a human-editable permissions YAML document, compiled into the build (not loaded at runtime). The repository sample is `config/permissions/sample.yaml`, which is for testing and evaluation only and is neither a production policy nor legal advice. Edit or replace the compiled-in file and rebuild to change the policy. There is no `[permissions]` block in `trusted-server.toml`. It defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule resolves at the top of the rules tree. See the [Permission Model](/guide/permission-model) for the schema and the repository sample. +The country and region permission rules are defined in the publisher's permission policy file, a human-editable YAML document that the settings name in `[permissions] policy` and that `ts config push --permissions ` supplies when the service is deployed, as [Permission Policy Configuration](#permission-policy-configuration) describes. No build carries a policy. The repository sample is `config/permissions/sample.yaml`, which is for testing and evaluation only and is neither a production policy nor legal advice. To change the policy, push the settings again with the changed file. The file defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule resolves at the top of the rules tree. See the [Permission Model](/guide/permission-model) for the schema and the repository sample. ## Consent Configuration @@ -851,7 +851,7 @@ legal advice. `[permissions]` names the publisher's permission policy file, which decides which permissions each request has. The [permission model](/guide/permission-model) describes what the file says. No -build carries a policy, so every deployment names its own file and supplies it +adapter build carries a policy, so every deployment names its own file and supplies it with `ts config push --permissions `. ### `[permissions]` From 289b0b5d542b910e1336573eab1b62d0ff5c9783 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:31:19 +0000 Subject: [PATCH 18/19] The configuration guide says no adapter build carries a policy --- docs/guide/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 8bbf71bfc7..97cb55004c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -802,7 +802,7 @@ A module advertises the technical permissions its data use requires, and Trusted ### Country and region rules (`permissions.yaml`) -The country and region permission rules are defined in the publisher's permission policy file, a human-editable YAML document that the settings name in `[permissions] policy` and that `ts config push --permissions ` supplies when the service is deployed, as [Permission Policy Configuration](#permission-policy-configuration) describes. No build carries a policy. The repository sample is `config/permissions/sample.yaml`, which is for testing and evaluation only and is neither a production policy nor legal advice. To change the policy, push the settings again with the changed file. The file defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule resolves at the top of the rules tree. See the [Permission Model](/guide/permission-model) for the schema and the repository sample. +The country and region permission rules are defined in the publisher's permission policy file, a human-editable YAML document that the settings name in `[permissions] policy` and that `ts config push --permissions ` supplies when the service is deployed, as [Permission Policy Configuration](#permission-policy-configuration) describes. No adapter build carries a policy. The repository sample is `config/permissions/sample.yaml`, which is for testing and evaluation only and is neither a production policy nor legal advice. To change the policy, push the settings again with the changed file. The file defines named **groups** (baselines such as `gdpr-eu`, `gdpr-uk`, `us-opt-out`) and **rules** that map a country or country/state to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no rule resolves at the top of the rules tree. See the [Permission Model](/guide/permission-model) for the schema and the repository sample. ## Consent Configuration From 40cf6a30b2536291c702d53561a02589c1b6ea07 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 14:33:48 +0000 Subject: [PATCH 19/19] The policy guard steps run on Linux alone From this pull request the Axum job also runs on Windows, where a step's default shell is PowerShell. The step that proves the guard refuses the sample is written for bash, so it failed to parse there, and the step that checks the release WASM checked nothing. The WASM is the same on every system, so both steps now run on Linux alone. --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4d307f800..3f9c95386c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -126,13 +126,17 @@ jobs: - name: Verify Fastly WASM release build run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + # The guard's two steps are written for bash and run on Linux alone, + # because the WASM they check is the same on every system. - name: Check that no permission policy is compiled into the Fastly WASM + if: runner.os == 'Linux' run: ./scripts/check-no-compiled-policy.sh target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm # The sample carries both markers the guard looks for, so a guard that # stops finding them passes the sample and fails the job here. Exit 1 is # the refusal; exit 2 means the guard could not run. - name: Check that the policy guard refuses a file that carries a policy + if: runner.os == 'Linux' run: | status=0 ./scripts/check-no-compiled-policy.sh config/permissions/sample.yaml || status=$?