Skip to content

Request 51Degrees client hints from one middleware - #1386

Open
jwrosewell wants to merge 60 commits into
feature/43-no-modules-cratefrom
feature/44-client-hints
Open

jwrosewell wants to merge 60 commits into
feature/43-no-modules-cratefrom
feature/44-client-hints

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/shared/51degrees 10 files, 2631 lines added, 0 removed
crates/middleware/51degrees 4 files, 1727 lines added, 0 removed
docs/superpowers 1 file, 330 lines added, 0 removed
docs/guide 3 files, 191 lines added, 0 removed
Cargo.lock 1 file, 43 lines added, 0 removed
crates/trusted-server-adapter-axum/tests 2 files, 37 lines added, 15 removed
trusted-server.example.toml 1 file, 31 lines added, 0 removed
Cargo.toml 1 file, 4 lines added, 0 removed
crates/trusted-server-adapter-axum/src 1 file, 3 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/src 1 file, 3 lines added, 0 removed
crates/trusted-server-adapter-fastly/src 1 file, 3 lines added, 0 removed
crates/trusted-server-adapter-spin/src 1 file, 3 lines added, 0 removed
crates/trusted-server-cli/src 1 file, 3 lines added, 0 removed
.cargo/config.toml 1 file, 2 lines added, 2 removed
CHANGELOG.md 1 file, 1 line added, 0 removed
crates/trusted-server-adapter-axum/Cargo.toml 1 file, 1 line added, 0 removed
crates/trusted-server-adapter-cloudflare/Cargo.toml 1 file, 1 line added, 0 removed
crates/trusted-server-adapter-fastly/Cargo.toml 1 file, 1 line added, 0 removed
crates/trusted-server-adapter-spin/Cargo.toml 1 file, 1 line added, 0 removed
crates/trusted-server-cli/Cargo.toml 1 file, 1 line added, 0 removed

Closes

Closes #1333

Spec

Spec: 51degrees-client-hints

Test plan

CI on this pull request at 63b6bb3 passed all 20 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 63b6bb3

Checklist

  • Feature PRs: spec linked above, ending in a review log of two or more review rounds
  • 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

On top of the modules crate removal (#1384). Issue: #1333.

What changed after the 51Degrees PR review

Agreed at the review on 23 October, point 2. The 51Degrees loading cache is pluggable through modules, and the publisher asset feature becomes a module.

  • The 51Degrees crates say answers are held only where a cache is linked. The shared crate's README and comments say so, and the middleware's tests link the 51Degrees loading cache module as a deployment would.
  • The 51Degrees guide says a stock build asks the 51Degrees service on every page navigation the middleware is placed for, because it links no loading cache. So do the configuration guide, the example settings and the changelog, which say that answers are held only where a deployment links a loading cache module.
  • This pull request's copy of the 51Degrees client hints design takes its final text from Add the integration seam design specs #1342, which says the same.

Agreed at the review on 23 October, point 1. No configuration or permission policy is compiled into the WASM, and a WASM without a policy does not start. Since #1345 a loaded domain carries its permission policy and every services graph needs one, so the tests below give theirs the sample policy.

  • The 51Degrees test services carry the sample policy.

Fixes found in review

Kept current

What this adds

What changes.

A deployment using 51Degrees gets client hints from one middleware. crates/middleware/51degrees is the 51degrees.client_hints serve middleware, and crates/shared/51degrees is what every 51Degrees module shares: the vendor's table, the one call to the service and its held answer, the evidence the service wants and the hint names a device module reads.

[shared.51degrees]
endpoint = "https://cloud.example.com/api/v4/"
license_key = "..."

[cache.51degrees-answers]
# Refused unless the build links crates/loading-cache/51degrees.
module = "51degrees"

[[serve]]
media_type = "text/html"
middleware = ["51degrees.client_hints"]

[middleware.51degrees.client_hints]
critical = true

How it works.

  • The middleware. A request filter asks the service which hints the reader's browser can send, for a page navigation whose path a [[serve]] entry naming the middleware covers, sending the User-Agent and the Sec-CH-UA hints and nothing else of the request. It loads the answer through the declared cache 51degrees-answers, which holds it only where the build links a loading cache module, and leaves the names in the request state under the vendor's id for a device module to read. On each page such an entry covers it writes the Delegate-CH tag in the head and, through the headers step, Accept-CH, Critical-CH (the critical switch from its own table, on by default) and Permissions-Policy for the delegation by header. Each keeps every line the response already carries, and Accept-CH and Critical-CH are left off where the request already carries every hint the service names and the response names no Accept-CH set of its own.
  • The shared crate. [shared.51degrees] holds endpoint, timeout_ms and license_key, declared as a secret setting, and refuses a key it does not know. The service is called over the platform's HTTP client in the wire shape the vendor's engine sends, with no discovery round trip.
  • Where the answer is held. In the declared cache 51degrees-answers, selected and sized in [cache.51degrees-answers] as every declared cache is, and only where the build links a loading cache module. No stock build links one, so a stock build asks the service on every page navigation the middleware is placed for.
  • A page the filter leaves names on keeps to the origin path and is sent private and not stored, as core does for any request a module leaves state on.

Why

Anyone taking the stack with 51Degrees should get the benefit of client hints by naming one entry, as a deployment using InMobi Choice gets its consent prompt by naming one module. The code is the vendor's to maintain and lives in the vendor's crates, and core gains nothing, because the middleware uses the serve phase, the headers step and the middleware table the stack already has.

What breaks

A deployment without [shared.51degrees] is unchanged. So is one that has the table and no [[serve]] entry naming 51degrees.client_hints, which makes no call to the service and writes no header and no tag.

A deployment on the Fastly adapter that adds the table and an entry changes the pages that entry covers.

  1. For each page navigation under the entry's path, when no answer is held, one request goes to the 51Degrees service. It carries the license key, the list of properties asked for, and the User-Agent and Sec-CH-UA hint headers the browser sent, and nothing else of the request (no address, no cookie and no other header). Answers are held only where the build links a loading cache module, so a stock build asks the service on every such navigation. Where one is linked, one held answer serves every browser that sends the same values, and an answer is held for an hour at most.
  2. A page the service names hints for is kept to the origin path, so it is never read from a stored template or stored as one, and an HTML response with a body is sent private, no-store. A browser the service names no hints for gets its pages as before.
  3. With critical on, the browser repeats its first navigation to carry the hints, so that page is built twice.
  4. The license key has to be in the secret store before the deploy that carries the table, because the settings look it up as they load.

On the Axum, Cloudflare and Spin adapters the table is accepted and the license key is still required, but nothing is asked of the service and nothing is written, because only the Fastly adapter runs request filters.

The commits

  1. A deployment using 51Degrees gets client hints from one middleware.
  2. The lock lists the two 51Degrees crates.
  3. A merge of the modules crate removal, after which each adapter and the ts command list the middleware's builder after the ads.txt module, the crate describes the middleware it supplies, and the shipped module test plants an unknown key in the vendor's shared table for a builder that table runs.
  4. The probe's misspelt cache test validates with the builders startup links, which now declare the middleware's cache.
  5. The client hints design states what the filter runs on, what it sends and what it costs, and takes its round 2 corrections.
  6. Client hints are not asked for again when the browser already sends them. A browser keeps the hints an origin asked for until a response names a different set, so the middleware leaves Accept-CH and Critical-CH off the response to a page request that already carries every hint the service names, unless the response names an Accept-CH set of its own. Permissions-Policy and the Delegate-CH tag are still written on every page the entry covers.
  7. The client hints filter asks the service only where an entry places it. The filter was registered by [shared.51degrees] alone, so it called the service on every page navigation once the table existed, whatever the [[serve]] entries said, and kept the page to the origin path as private and not stored with no header written. It now asks only when a [[serve]] entry whose path covers the request path names 51degrees.client_hints, the rule the headers step uses.
  8. The client hints call sends the User-Agent and Sec-CH-UA hints alone. The call also sent the visitor's address and the 51D_ cookies, and the held answer was keyed on all three. It now sends the User-Agent and every Sec-CH-UA request header, in name order, and no address, cookie or other header, so one held answer serves every reader whose browser sends the same values.
  9. Every line of a client hints header the response sends is kept. The middleware read only the first Accept-CH, Critical-CH and Permissions-Policy line a response carried, and core replaces every line of a name with the one line written, so a header sent on two lines lost the second. The middleware now joins every line in order and adds the hints after it.
  10. The client hints design and docs state the call's scope, evidence and merging, and a header comment wraps as one paragraph.
  11. The shipped modules test that plants an unknown setting in [shared.51degrees] gives the table a key for each secret setting the builder declares, so the unknown setting is the table's only fault.
  12. The design page's header names the pull request that carries it.
  13. The 51Degrees answer cache builds its cache spec without the detachable flag, which core's cache spec no longer has, because every load runs in the caller that leads it.
  14. The 51Degrees crates say the service's answers are held only where a deployment links a loading cache module, and the middleware's tests link the 51Degrees loading cache module as such a deployment does.
  15. The 51Degrees guide, the configuration guide, the example settings and the changelog say a stock build asks the service on every page navigation the middleware is placed for, and that [cache.51degrees-answers] module = "51degrees" is refused unless the build links crates/loading-cache/51degrees.
  16. The 51Degrees test services carry the sample permission policy.
  17. This row's copy of the 51Degrees client hints design takes its final text from Add the integration seam design specs #1342.
  18. Merges of the series below, which carry the fixes made lower in the stack, the page origin change and Tech Lab main at 7aa5c99.

What is not in this pull request

A 51Degrees device module, so nothing reads the names the filter leaves yet. Request filters on the Axum, Cloudflare and Spin adapters, which run none, so on those adapters the middleware asks the service nothing and writes nothing. Whether the call to the service needs a permission, and how long an answer is held. The design page lists both as open questions.

A browser sends its high entropy client hints only when a response asks
for them with Accept-CH, retries its first navigation carrying them only
when Critical-CH names them, and lets a third party read them only when
the page delegates them, by a Delegate-CH tag in the page as served or a
Permissions-Policy header. Nothing in the stack wrote any of that for a
deployment whose device detection is 51Degrees, so every first page view
of a session was priced on the User-Agent alone.

The 51degrees.client_hints serve middleware, in crates/middleware/51degrees,
writes all of it where a [[serve]] entry names it. As it filters a page
request it asks the 51Degrees service which hints it wants from that
browser, through the cache the 51Degrees modules share, and leaves the
names in the request's state. From that list it writes Accept-CH,
Critical-CH and Permissions-Policy through the headers step, each keeping
what the response already said under that name, and the Delegate-CH tag
in the head. No hint name is written down in the code, so the headers
follow the service. The critical switch is in the middleware's own table,
[middleware.51degrees.client_hints], on by default.

What the 51Degrees modules share is crates/shared/51degrees: the table
[shared.51degrees] with endpoint, license_key and timeout_ms, whose
presence runs the 51Degrees builder, the license key every server side
call presents and the refusal when the table has none, the one call to the
service for a request's evidence as a form POST over the platform's HTTP
client with its answer held in the loading cache so a question many
requests ask at once is asked once, the evidence a request gives, and the
type a module reads the hint names from the request's state by.

The answers are held in the declared cache 51degrees-answers, selected and
sized in [cache.51degrees-answers] as every declared cache is, so a stock
build holds them with its one loading cache module without a line in the
document. An answer is held for an hour at most.

The stock list ships the builder, so every adapter and the ts tool take
it, and its tests hold the new row, the fixture settings and the hook
order. The unknown-setting test now plants the key in a vendor's shared
table for a builder that table runs. Both crates name their maintainers.
Cargo.lock gains the entries for trusted-server-shared-51degrees and trusted-server-middleware-51degrees, which the workspace now holds, and nothing else moves.
The modules crate is gone, so the client hints middleware is listed by
each adapter and the ts command after the ads.txt module, describes the
middleware it supplies, and the shipped module test plants an unknown key
in the vendor's shared table for a builder that table runs.
…inks

The adapter's own list now declares the client hints module's cache, so
the deploy check reads the same builders startup composes, the adapter's
and the deployment's after them.
… and what it costs

Review round 1 found that the request filter runs wherever the table is
present and not only where an entry covers the page, that the cost to
existing pages was not stated, that the timeout of each adapter and the
answers on failure were not stated, and that nothing said what each
navigation sends to the service. The design now says each, and records
that only the Fastly adapter runs request filters, so the other three
adapters make no call.

It also gives the cache key, the settings, what the headers keep, the
untested paths and the questions on consent, and drops a related spec
that exists in no branch. The guide, the changelog and the middleware
readme no longer say the filter follows the entries.
Review round 2 found that the related specs were named under a pull
request that is now closed, that the guide still said the first call to
the service waits within timeout_ms when the connection can take up to
one second and each gap between bytes gets its own timeout_ms, and that
code comments still say more than the design does.

The design now names the pull request that holds the design documents,
the guide matches the section on failure, and section 3 lists the
comments left for a later change.
@jwrosewell
jwrosewell added this pull request to stack #1401 October 15, 2026 17:56
A browser keeps the hints an origin asked for with Accept-CH until a
response from that origin names a different set, and only an empty
Accept-CH clears them (Client Hints Infrastructure, section 3.2). Chrome
155 behaves the same way: after one response with Accept-CH, the high
entropy hints arrive on every later navigation and in new tabs, through
pages that send no Accept-CH.

So the middleware now leaves Accept-CH and Critical-CH off the response to
a page request that already carries every hint the service names. It still
writes them when the response names an Accept-CH set of its own, because
that set would replace the one the browser holds, and when any name is
missing, which is how a hint the service starts asking for reaches the
browser. Chromium sends three low entropy hints unasked, so only the whole
list counts, and a hint counts whatever its value, because
Sec-CH-UA-Model is an empty string on a desktop.

Permissions-Policy and the Delegate-CH tag apply to their own page only, so
every page still gets them.

The filter leaves a marker beside the names when the request carried them
all, and response_headers reads it. The guide, the configuration table, the
crate README, the design note and the changelog say the same.
@jwrosewell
jwrosewell removed this pull request from stack #1401 October 15, 2026 20:51
The request filter is registered by [shared.51degrees] alone, so it called
the service on every page navigation once the table existed, whatever the
[[serve]] entries and their paths said. A deployment with the table and no
entry naming the middleware, or an entry for /news/ only, still sent each
navigation's evidence to the service and kept the page to the origin path
as private, no-store, with no headers written.

The filter now asks only when a [[serve]] entry whose path covers the
request path names 51degrees.client_hints, which is the rule the headers
step already uses (PhaseEntries::for_path), so the call is made for exactly
the pages whose responses the middleware writes on.

Tests: a table with no entry makes no call, and an entry with
path = "/news/" calls for /news/today and not for / or /newsletter.
cargo test -p trusted-server-middleware-51degrees, 40 passed.
Each navigation sent the service the visitor's address, the User-Agent and
the 51D_ cookies, and the answer cache was keyed on all three, so a held
answer served only a request from the same address with the same cookies.
The address and cookies were there so a future 51Degrees module could share
the answer, and no such module exists.

The evidence is now the User-Agent and every Sec-CH-UA request header
(Sec-CH-UA and each header named Sec-CH-UA-...), by lower case name in name
order, sent under the bare header names the service reads. No address, no
cookie and no other header is sent. The cache key is built from the fields
sent, as before, so one held answer now serves every reader whose browser
sends the same values. The filter no longer reads the client address, and
the cookie and address handling in the shared crate is removed with
BROWSER_EVIDENCE.

The answer lifetime stays one hour. Its comment gave the address as the
reason, which no longer applies, so the comment now says only what the
code does.

Tests: no address and no cookie reaches the request the client builds
(shared crate) or the request the filter causes (middleware crate), two
readers on the same browser at different addresses make one call, and the
evidence holds only the User-Agent and the Sec-CH-UA hints, in name order.
Each guard test was checked to fail when every header is sent.
cargo test: shared 47 passed, middleware 41 passed.
The middleware read only the first Accept-CH, Critical-CH and
Permissions-Policy line a response carried, and core then wrote each header
with HeaderMap::insert (write_response_headers in
crates/trusted-server-core/src/middleware.rs), which replaces every line of
that name. A response sending a header on two lines lost the second.

The middleware now reads every line under each name, joins them into one
list in order, and adds the hints after it, so nothing the response said is
dropped. Critical-CH is read by the same code, so it keeps every line too.

Test: a response sending each of the three headers twice keeps both lines
of each, in order, with the hints after them.
cargo test -p trusted-server-middleware-51degrees, 42 passed. cargo clippy
on both 51Degrees packages with --all-targets -- -D warnings is clean.
… merging

The design, the guide page, the changelog entry and the two crate readmes
now say what the code does after the three fixes before this commit.

- The request filter asks the service only for a page navigation whose path
  a [[serve]] entry naming the middleware covers, by the rule the headers
  use, so an entry's path limits the call as well as the headers and the
  tag, and a table with no entry makes no call.
- The call sends the User-Agent and the Sec-CH-UA hints alone, with no
  address and no cookie, and one held answer serves every reader whose
  browser sends the same values.
- Accept-CH, Critical-CH and Permissions-Policy keep every line the
  response carries.

The design moves those three out of section 3 and out of the open
questions, updates section 4 with the new tests (six untested paths
remain), and adds a revision record row. The permission question stays
open, now with what was found. The permission model names a data use by an
IAB Tech Lab Privacy Taxonomy Data Use, none of which names device
detection or client hints, and the nearest general ones are granted
everywhere, consent gated in the GDPR groups, or denied everywhere in the
sample policy, so choosing one is a policy decision. The hour an answer is
held was chosen for the address, which is no longer sent, so whether it
should be longer is added as a second open question.

Prettier --check passes on every changed Markdown file.
The comment on client_hint_headers broke a sentence across two short lines when the merging fix rewrote its start. It now reads as one paragraph wrapped near 80 columns. No code changes.
… declares

The push check now reads the secret settings of a vendor's shared table,
so the test that plants [shared.51degrees] holding only no_such_setting
was refused for its empty license_key before the unknown setting was
reached, and every_linked_module_rejects_a_setting_it_does_not_know failed
on the 51degrees builder. The planted table now names a key for each
secret setting the builder declares, read from the builder itself so that
a later module with secrets is covered too, which leaves the unknown
setting as the only fault in the table.
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell jwrosewell closed this Oct 19, 2026
@jwrosewell jwrosewell reopened this Oct 19, 2026
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell jwrosewell changed the title A deployment using 51Degrees gets client hints from one middleware Request 51Degrees client hints from one middleware Oct 19, 2026
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:24
The shared crate's README, the module comments of engine_cache.rs,
client.rs and evidence.rs, and the comments on CloudPipeline and
accept_ch said that a question many requests ask at once is asked of the
cloud service once. That holds only where a deployment links a loading
cache module, and a stock build links none, so each now says so. The
test services' comment calls the cache they use the 51Degrees module's
rather than the stock one.

The client hints middleware's tests link the 51Degrees loading cache as
a deployment would, so the helper stock_builders() is
linked_builders(), and the test that the shared table runs the
middleware and a linked cache holds its answers is named and worded for
a linked cache rather than the stock one.
The 51Degrees guide said that a stock build holds the service's answers
with its one loading cache module, and that only the first call for a
browser's values costs a round trip. A stock build links no loading
cache module, so it asks the service on every page navigation the
middleware is placed for, and the guide, the configuration guide, the
example settings and the changelog now say that the answers are held
only where a deployment links a loading cache module.
@jwrosewell
jwrosewell removed this pull request from stack #1422 October 28, 2026 11:47
@jwrosewell
jwrosewell added this pull request to stack #1465 October 28, 2026 18:28

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.

A deployment using 51Degrees has no client hints middleware

1 participant