Repository navigation
Add device and geo module selection with the host-signal Edge Cookie module - #1344
Draft
jwrosewell wants to merge 34 commits into
Draft
jwrosewell wants to merge 34 commits into
jwrosewell wants to merge 34 commits into
Conversation
…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
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.
This was referenced Oct 16, 2026
This was referenced Oct 17, 2026
jwrosewell
added this pull request to stack #1408
October 17, 2026 09:05
jwrosewell
removed this pull request from stack #1408
October 18, 2026 20:15
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
crates/trusted-server-core/srccrates/trusted-server-adapter-fastly/srccrates/device/fastlycrates/trusted-server-adapter-cloudflare/srccrates/geo/fastlycrates/trusted-server-adapter-spin/srccrates/geo/README.mdCargo.lockcrates/trusted-server-adapter-axum/srccrates/fastly.tomltrusted-server.example.tomlcrates/device/README.mdcrates/trusted-server-integration-tests/fixtures.cargo/config.tomlCargo.tomlcrates/trusted-server-adapter-fastly/Cargo.tomlCloses
Closes #780
Closes #781
Test plan
CI on this pull request at 804af8d passed all 16 checks.
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code (clippy deniesunwrap_usedin CI)logmacros as AGENTS.md asks (notprintln!)Stacks on the Edge Cookie module seam (#1343), and depends on its
EdgeCookieModuletrait and module-code registry, which the host-signalmodule 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] moduleselects the classifier. The defaultbuiltinreads theUser-Agent alone and makes no host call. The opt-in
fastlymodulestrengthens the browser and bot gate with the host's TLS JA4 and HTTP/2
signals
(
crates/device/fastly).[geo] moduleselects geolocation. In this pull request the hostplatform's lookup remains the default, matching the behavior before the
selector existed, with
module = "none"as an explicit opt-out that sendsno 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.
the same
build_geo_moduleselector, so[geo] modulebehaves the sameeverywhere rather than on Fastly alone.
[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 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] passphrasenames 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.yamlrather than compiled into the build.References #780 and #781. Decomposes #838. Spec baseline from #986.