Skip to content

Add device and geo module selection with the host-signal Edge Cookie module - #1344

Draft
jwrosewell wants to merge 34 commits into
feature/1-ec-providerfrom
feature/2-device-geo
Draft

jwrosewell wants to merge 34 commits into
feature/1-ec-providerfrom
feature/2-device-geo

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 17 files, 1673 lines added, 233 removed
crates/trusted-server-adapter-fastly/src 3 files, 134 lines added, 61 removed
crates/device/fastly 2 files, 123 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/src 2 files, 108 lines added, 13 removed
crates/geo/fastly 2 files, 73 lines added, 0 removed
crates/trusted-server-adapter-spin/src 2 files, 23 lines added, 9 removed
crates/geo/README.md 1 file, 21 lines added, 0 removed
Cargo.lock 1 file, 19 lines added, 0 removed
crates/trusted-server-adapter-axum/src 2 files, 15 lines added, 9 removed
crates/fastly.toml 1 file, 13 lines added, 0 removed
trusted-server.example.toml 1 file, 12 lines added, 0 removed
crates/device/README.md 1 file, 10 lines added, 0 removed
crates/trusted-server-integration-tests/fixtures 1 file, 5 lines added, 0 removed
.cargo/config.toml 1 file, 4 lines added, 4 removed
Cargo.toml 1 file, 4 lines added, 0 removed
crates/trusted-server-adapter-fastly/Cargo.toml 1 file, 2 lines added, 0 removed

Closes

Closes #780
Closes #781

Test plan

CI on this pull request at 804af8d passed all 16 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at 804af8d

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on the Edge Cookie module seam (#1343), and depends on its
EdgeCookieModule trait and module-code registry, which the host-signal
module below uses. It is the second of the five pull requests (#1343 to #1347) that decompose
the combined module change (#838), as the review of the earlier design specs
(#986) asked.

The design specs for the series are carried by the spec pull request (#1342).
The spec for this pull request, shared with #1343, is
2026-07-30-pluggable-providers-design.md,
which covers the Edge Cookie, device and location modules together.

What this pull request does

Device classification and geolocation become selectable modules, mirroring
the Edge Cookie seam and using the same configuration shape.

  • [device] module selects the classifier. The default builtin reads the
    User-Agent alone and makes no host call. The opt-in fastly module
    strengthens the browser and bot gate with the host's TLS JA4 and HTTP/2
    signals
    (crates/device/fastly).
  • [geo] module selects geolocation. In this pull request the host
    platform's lookup remains the default, matching the behavior before the
    selector existed, with module = "none" as an explicit opt-out that sends
    no client IP to any host geo service. The switch to no geolocation by default
    lands with the permission model (Add the permission model with the Privacy Taxonomy vocabulary #1345), together with the permission
    baseline that makes a deployment without geolocation workable, so this pull
    request alone changes no deployment's geo behavior.
  • Every adapter (Fastly, Axum, Cloudflare, Spin) routes its host geo through
    the same build_geo_module selector, so [geo] module behaves the same
    everywhere rather than on Fastly alone.
  • The module tables ([device], [geo], [ec.hmac], [ec.host_signals])
    reject keys they do not know at startup, so a mistyped setting fails loudly
    instead of silently leaving a default in place. A table the selector does not
    name is refused the same way, as is a device or geo module name this
    build does not have.
  • The host-signal Edge Cookie module ships as an opt-in built-in. It creates
    the identifier from the host TLS and HTTP/2 signals plus the client IP, and
    with no host signals at all it defers with a warning rather than falling back
    to an identifier made from the IP alone. Its identifiers carry the hs00~
    code from the module-code registry that Add a pluggable Edge Cookie module seam with the built-in HMAC module #1343 introduces, which fixes the
    collision defect the review of Add pluggable Edge Cookie, device, and geo providers gated by a permission model #838 found, where host-signal identifiers
    shared the HMAC format and key space. Its [ec.host_signals] passphrase
    names a secret-store key, as the HMAC passphrase does. Push validation checks
    the name as a key reference, and the resolved value is held to the same
    32-byte minimum. Whether host TLS and HTTP/2 processing ships in the series
    is a policy question put to the task force in Host TLS/HTTP-2 signal processing: the separate design sign-off row 22 requires #1071, which is the separate
    design that sign-off row 22 asks for. The proposal there is to close the row
    with the capability opt-in, both uses permission-gated, and the policy
    expressed in permissions.yaml rather than compiled into the build.

References #780 and #781. Decomposes #838. Spec baseline from #986.

jwrosewell and others added 26 commits September 2, 2026 15:05
…e provider

Second slice of the PR 838 decomposition. Device classification and
geolocation become selectable providers, mirroring the Edge Cookie
provider seam:

- [device] provider selects the classifier. The built-in default reads
  the User-Agent alone and makes no host call; the opt-in fastly
  provider strengthens the browser/bot gate with the host's TLS JA4 and
  HTTP/2 signals (crates/device/fastly).
- [geo] provider selects geolocation. The host platform's lookup is the
  default, matching the behavior before the selector existed, and
  provider = "platform" spells the same choice explicitly
  (crates/geo/fastly wraps the Fastly host lookup behind the
  PlatformGeo trait). provider = "none" opts out entirely, so a client
  IP is never sent to any host geo service. The disabled-by-default
  flip ships with the permission model in the next slice, which adds
  the jurisdiction baseline that makes a no-geo deployment viable.
- Every adapter routes its host geo through the same build_geo_provider
  selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider
  identically, so the selector is not a Fastly-only behavior.
- The provider configuration sections ([device], [geo],
  [ec.providers.hmac], [ec.providers.host-signals]) reject unknown
  keys at startup, so a mistyped key fails loudly instead of silently
  selecting a default.
- The host-signal Edge Cookie provider arrives with the capability it
  needs: the Fastly adapter injects the TLS/HTTP-2 signals as a
  HostSignals service, and the provider mints from them plus the client
  IP. With no host signals at all it defers with a warning rather than
  degrading to an IP-only identifier.
- Device signals move to a field-based DeviceSignals derived in the
  adapter (derive_ua_only for hosts without host signals).
- The new crates join the fastly cargo aliases so they build, lint, and
  test in CI rather than compiling only transitively.
The geo and device selectors are optional, and both serialized as
`"provider": null` when unset rather than being left out.

That is invisible today, because the whole section is skipped while every field
in it is default. It stops being invisible in the permission model PR, which
makes `[geo] default_country` required, so the geo section is always emitted and
the null selector goes with it. A pushed config blob then carries a key that a
binary predating the selector rejects, which breaks an otherwise unchanged
`ts config push` during a rollout or a rollback. That is the same compatibility
rule the surrounding fields already follow.

Both fields now skip serialization when unset. The test serializes each section
directly rather than through Settings, because through Settings the section is
skipped as a whole here and the fault would not show until two PRs later.

Addresses: crates/trusted-server-core/src/settings.rs, optional selectors
serialized as null
The Cloudflare adapter resolves no region on upstream/main today and this
fixes it here. `build_geo` read no region header and `CloudflareGeo`'s
`lookup` returned `region: None` unconditionally, so no Cloudflare
deployment could ever place a visitor in a US state.

I traced the consequence rather than assuming it, and it holds.
`detect_jurisdiction` in `consent/jurisdiction.rs` reaches its US branch
only when the country is `US` and a region is present, so with no region
every US visitor fell through to `Jurisdiction::NonRegulated`. In
`allows_ec_creation`, the `NonRegulated` arm returns true and never reads
`ctx.gpc`, while the `UsState` arm blocks on `ctx.gpc` first and then on
a GPP US sale opt-out and the US Privacy string. A US visitor sending
Sec-GPC therefore had that opt-out ignored on Cloudflare, and an Edge
Cookie was created anyway. The same is true for the GPP and US Privacy
opt-outs, because none of them are consulted on the unregulated arm.

The region now comes from `cf-region-code`, which is the ISO 3166-2
subdivision code, rather than `cf-region`, which is the subdivision name.
The state rules in `[consent.us_states] privacy_states` are written as
two-letter codes, so a name would never match one and the fix would look
applied while changing nothing. This is a request header, read the same
way the adapter already reads `cf-ipcountry` and `cf-ipcity`, and like
those it needs the visitor-location managed transform, which the doc
comment now says.

The new test walks the whole chain in one place, from the header through
`build_geo` and `lookup` to `detect_jurisdiction` and then to
`allows_ec_creation` with Sec-GPC set, and asserts the opt-out is
honored. Putting `region: None` back fails it.

Addresses: crates/trusted-server-adapter-cloudflare/src/platform.rs,
where `CloudflareGeo::lookup` hardcoded no region and `build_geo` read no
region header, so US state privacy signals failed open.
Restoring the evidence accessors added a `with_headers` builder to
`OwnedRequestInfo` and `BorrowedRequestInfo`, and on both types it landed
between `with_request_target`'s doc comment and the function that
comment describes. So `with_request_target` was left undocumented and
without its `#[must_use]`, while `with_headers` carried a stray
attribute and a comment describing its neighbour.

The compiler reported the stray attribute and clippy separately asked
for a `#[must_use]` on `with_request_target`, which is the attribute
that had been severed from it. Both builders now sit with the
documentation and the attribute that belong to them, so a caller that
drops either builder's result is caught again.

This went unnoticed because the checks run against this branch were
`cargo check` and a cached build, neither of which re-reports warnings,
and the branch fails `cargo clippy` with warnings denied. It is fixed
here rather than further up the stack because this is the change that
introduced the builders, so every branch above inherited the fault.

Addresses: crates/trusted-server-core/src/evidence.rs, where both
`with_request_target` builders lost their documentation and their
`#[must_use]`.
CI compiles the test build with -D warnings, where this import is an error
because the one place that needs the type names it by its full path. The
seam branch already removes it, so remove it here where the device tests
first appear and the whole chain stays consistent.
The integration seam spec requires every vendor crate to carry a named
maintainer the way Prebid.js requires of every adapter. The two Fastly
host crates now declare their owner in package metadata, stating plainly
that the Trusted Server maintainers own them until a vendor owner steps
up, which is the ownership rule the reviews on #1084 asked the structure
to make explicit.
Brings upd/split/1-ec-provider at cb28ad3, which carries main at
066ea3c, into split/2-device-geo. Six files conflicted, all where this
branch's host signals meet main's compiled auction plan or main's
imports. Each resolution keeps both sides' changes, so nothing either
side does is dropped.

The Axum, Cloudflare and Spin adapters keep this branch's provider
calls, which pass the host signals (None in all three) as well as the
injected provider, with this branch's comments saying so. They then
build the orchestrator and the integration registry from main's
compiled auction plan, the way split/1 does.

The Fastly imports keep what each side still uses. app.rs keeps main's
StoreName alongside this branch's build_geo_provider and
FastlyHostSignals. main.rs drops the config_store_name import, because
main replaced its only use with the runtime store names, and keeps this
branch's device provider import. platform.rs keeps main's
BackendNamingPolicy and drops GeoInfo and PlatformGeo, which this branch
moved into the trusted-server-geo-fastly crate.
Main's #1036 resolves secret settings from the secret store by the
paths TrustedServerAppConfig::secret_fields lists, and push validation
skips the validators on those paths because they hold key names.
Merging main registered the [ec.providers.hmac] passphrase, but not this
branch's [ec.providers.host-signals] passphrase. A key name there failed
the passphrase length check when the configuration was pushed, and a
key name long enough to pass would have been used unresolved as the
HMAC key.

The path is now listed as an optional secret and checked as a key name.
EdgeZero matches secret paths against validation error keys verbatim,
and the derived validation keyed the block's errors by the Rust field
name host_signals, so EcProviders now validates each built-in block
under the key the configuration uses. New tests cover pushing a
host-signals key name, resolving one from the mapped store, and still
rejecting a short resolved value.
Brings upd/split/1-ec-provider at 1b88e42 into split/2-device-geo.
The five commits this branch lacked, on top of the cb28ad3 it
already had, answer two review threads on #1043 and correct what
reviewing those answers found.

- Pull sync, the admin lookup, /auction, the publisher navigation
  preload and /_ts/page-bids read and write identity-graph rows under
  the owning provider's canonical key (0e7f7eb and fb1bc28).
- The docs on a rejected provider effect say generation returns an
  error and the page is served without an Edge Cookie, with a test that
  a rejected header never reaches the finalized response (0980b73).
- A provider response's headers are kept only for a candidate that
  generation commits (cb6f717).
- The API reference lists the admin lookup's accepted EC ID forms, and
  the bare reader note names the function that writes EID updates
  (1b88e42).

The merge had no conflicts. The merged tree passes a native
all-targets check, 2,763 core tests, the Axum, Cloudflare and Spin
tests and the three wasm checks.
Every name an operator types into configuration is snake_case, so the built-in
host-signal provider is now selected with `[ec] provider = "host_signals"` and
configured with `[ec.providers.host_signals]`. The key constant, the serde name
of the typed block, the validation error key, the registered secret path
`ec.providers.host_signals.passphrase`, the placeholder-secret report, the two
startup messages that spell the key and the provider's own log line all follow.
This commit changes only that one name.

The `HostSignals` trait, the `host_signals` fields and arguments that carry it,
and the prose that calls this the host-signal provider are unchanged, because a
host capability is not a configuration value. The test secret-store key name
`host-signals-passphrase-key` is unchanged for the same reason, being a name an
operator picks for a secret rather than a provider name.

The Rust field name and the configuration name are now the same word, so the
`#[serde(rename)]` on the typed block goes and the field is declared the same
way as `hmac` beside it. That also retires the reason the `EcProviders`
validation keys were spelled out by hand, so the note above those keys is
rewritten to say what still holds, which is that each key has to match the
secret path `TrustedServerAppConfig::secret_fields` registers.

The old name is refused when settings load, which every adapter does before it
serves a request. The message names `host_signals` and says what to write. The
refusal comes before any block is looked for, because a block left behind under
the old name is captured as a vendor block, so the old selector would otherwise
find that block, pass the settings check, and fail later in provider resolution
with a message about an adapter that supplies no such provider. That is a
breaking change, accepted for a major release.

Tests. `cargo fmt --all -- --check` is clean and `cargo clippy -p
trusted-server-core --all-targets --all-features -- -D warnings` passes. The
core suite passes 2,764 tests and 5 doc-tests with 4 ignored, the new test among
them. The Axum adapter passes 43 tests across its three binaries, `cargo
clippy-axum` is clean, and `cargo check -p trusted-server-adapter-fastly
--target wasm32-wasip1` builds.
Brings the Edge Cookie provider block layout, where each provider has its
own [ec.<name>] table and an optional implementation = "<id>" line, under
the host-signal provider rename this branch already carried. Both changes
are the same decision applied to different parts of one section, so the
host-signal provider is now selected with [ec] provider = "host_signals"
and configured in [ec.host_signals], not [ec.providers.host_signals].

Four files conflicted.

crates/trusted-server-core/src/settings.rs. split/1 replaced the typed
EcProviders struct with EcProviderBlocks, a map of the blocks written
under [ec], in which only the hmac implementation keeps typed settings
and everything else is held as the raw values an adapter reads. This
branch had added host_signals as a second typed built-in beside hmac, so
the resolution gives it the same standing in the new model rather than
demoting it to raw values. EcProviderSettings gains a HostSignals
variant, EcProviderBlock gains host_signals_settings, EcProviderBlocks
gains host_signals_blocks beside hmac_blocks, read_provider_block reads a
host_signals block into the typed config, and From<HostSignalsProviderConfig>
builds the block. The block-required check in validate_provider_selection
now covers both implementations built into core, because both take a
passphrase. The placeholder-secret report names the block the operator
wrote, as it already did for hmac. The refusal of the old host-signals
spelling is kept and its message now sends the operator to
[ec.host_signals]. That refusal moved to the top of
validate_provider_selection and now covers a block left under the old
name as well as the selector, because split/1's snake_case rule runs over
the block names first and would otherwise refuse host-signals with the
general rule instead of the message naming the spelling to write. The
tests of both branches are kept, with this branch's host-signal test
rewritten onto the new block layout.

crates/trusted-server-core/src/ec/provider.rs. Both branches edited the
resolution arms and the module documentation. build_provider keeps the
host_signals parameter this branch added, because the host-signal
provider needs a service only some hosts supply, and the host-signal arm
of resolve_named_provider now reads its settings out of the block the
selector names rather than a fixed field. BUILTIN_PROVIDER_KEYS keeps
both entries under split/1's wording, which calls them implementation
ids. The documentation keeps split/1's [ec.<name>] spelling together with
this branch's statement that the host-signal provider is built per
request.

crates/trusted-server-core/src/config.rs. The registered secret path for
the host-signal passphrase drops the removed providers segment and
becomes ec.host_signals.passphrase. Push-time validation reads the
host-signal blocks the same way it reads the hmac ones, and the reader
that finds a built-in provider configured under a label of the operator's
choosing now looks for either implementation, so a labeled host-signal
block resolves its passphrase too.

crates/trusted-server-core/src/config_payload.rs. Both branches added a
key to the test secret store. Both are kept, being separate keys serving
separate tests.

Two follow-on fixes outside the conflicts. The tests this branch had
written against the removed EcProviders type now go through a new
select_host_signals_provider test helper beside select_hmac_provider, and
the build_provider calls split/1 had reduced to two arguments take the
host_signals argument again.

Tests. cargo fmt --all -- --check is clean and cargo clippy -p
trusted-server-core --all-targets --all-features -- -D warnings passes.
The core suite passes 2,782 tests and 5 doc-tests with 4 ignored. The
Axum adapter passes 43 tests across its four binaries, cargo clippy-axum
is clean, and cargo check -p trusted-server-adapter-fastly --target
wasm32-wasip1 builds.
Carries up the resolutions from split/1, where main's second entry point,
`build_state_with_services`, meets the composition-root provider check.

This branch had already given that check and `build_reusable_provider` a
`host_signals` argument, so each call now passes `None` for host signals and
whatever provider a caller's supplied services already carry. The free
function that handed the resolved provider to every request is gone on both
adapters and its job sits in `services_for_request`, the method main added,
which passes the settings this branch made `build_runtime_services` take.
Brings the refreshed Edge Cookie seam, and through it upstream main at
7a0ecb4, into the device and geo branch. The seam's names say module, so
every line here that used one of its names says module too, and the
conflicts settled as that rename applied to this branch's own text:

- The Fastly entry point keeps this branch's device and geo wiring with
  upstream's cache directive helper and PlatformCacheIntent, and drops the
  RuntimeServices and PlatformKvStore imports upstream no longer needs.
- The cargo aliases keep this branch's device and geo crates and gain
  upstream's test-fastly-reuse alias.
- In the seam, the host-signal implementation keeps its resolution arm,
  its key constant and its two tests, and the uninjected-module test
  covers build_module and the startup check together as the seam's does,
  with this branch's host_signals argument.
- build_reusable_module keeps its three arguments, and the composition
  roots call it by that name.

This branch's own device and geo names still say provider, and are
renamed in a commit of their own.
The Edge Cookie settings refused the old host-signals spelling with a
message naming the spelling to write. No configuration in this repository
uses it, so the constant, the refusal in validate_module_selection,
names_retired_module, the test of the refusal, the doc paragraphs
describing it and its row in the error reference are gone.

An old spelling now fails as any unknown implementation does when the
module is built at startup, with a message listing the implementations the
deployment has, which an_unknown_implementation_fails_naming_the_known_ones
covers. The settings test kept the one assertion that was not about the
old spelling, that a host_signals block is read as the built-in module's
own settings, as a test of its own.
The fixed secret list already holds ec.hmac.passphrase and
ec.host_signals.passphrase, but labeled_provider_block_names also listed a
block named after one built-in that configures the other. The same path
was then resolved twice, the second time reading the secret as a key
name, and the load failed with a message that read as a missing store
entry.

A block named after either built-in is now left off the labeled list,
because the fixed list covers it whichever it configures.

Test a_built_in_block_naming_the_other_built_in_resolves_its_passphrase_once
fails without the change.
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 Edge Cookie seam, which
already says module, and only those words: every provider word was
compared with upstream main, and a word that exists there is untouched.

- DeviceProvider is DeviceModule, and BuiltinDeviceProvider,
  FastlyDeviceProvider and the test stand-ins are BuiltinDeviceModule,
  FastlyDeviceModule and so on. DeviceConfig's provider_key is module_key,
  and build_device_provider and build_geo_provider are build_device_module
  and build_geo_module.
- HostSignalProvider is HostSignalModule, HostSignalsProviderConfig is
  HostSignalsModuleConfig, and HOST_SIGNALS_PROVIDER_KEY is
  HOST_SIGNALS_MODULE_KEY.
- The [device] and [geo] selectors read module, as [ec] does, so the
  environment overrides read TRUSTED_SERVER__device__module and
  TRUSTED_SERVER__geo__module, and labeled_provider_block_names is
  labeled_module_block_names.
- Test helpers and test names say module, and the plain word says module
  wherever it meant a device, geo or Edge Cookie module, in the core code,
  the adapters, the device and geo crates, the example settings, the
  fixtures and the guides.
Brings the naming module, so a module is named by its crate folder, and the
Edge Cookie side resolving a written name within its type folder. The
host-signal module's key and the type folder sit side by side.
- The Cloudflare region test says why the region header has to reach the
  privacy outcome.
- The Fastly geo module's re-export and the geo selector's default are
  described as they are.
- The integration fixture's note on the geo selector no longer promises a
  setting that a later change does not add.

No code changes.
# Conflicts:
#	crates/trusted-server-adapter-fastly/src/main.rs
#	crates/trusted-server-core/src/test_support.rs
@jwrosewell
jwrosewell removed this pull request from stack #1401 October 15, 2026 20:51
The geo module README said the default deployment resolves no location
until a module is selected. build_geo_module returns the adapter's host
lookup when [geo] module is unset or "platform", and DisabledGeo only for
"none", so the README now says that.
The Fastly entry point sets x-ts-tls-ja4 and x-ts-h2-fingerprint on
every request so the adapter can build its HostSignals service. Neither
name was in INTERNAL_HEADERS, so the Lockr API proxy and the five
Permutive proxy routes, which copy every x- request header that list
does not name, sent both values to those vendors, and the publisher
origin request, which carries the reader's other headers onward, sent
them to the origin.

The two names now sit in HOST_SIGNAL_HEADERS, from which
INTERNAL_HEADERS is built, and rewrite_origin_request removes them from
the origin request. New tests drive the Lockr API proxy, each Permutive
route and the publisher origin fetch, streamed or not, with both headers
set, and all three failed before this change. Listing the names in
INTERNAL_HEADERS also refuses them as trusted client IP header names.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants