Skip to content

Add the client-set Edge Cookie value path - #1046

Open
jwrosewell wants to merge 88 commits into
IABTechLab:mainfrom
jwrosewell:split/4-client-resolve
Open

Add the client-set Edge Cookie value path#1046
jwrosewell wants to merge 88 commits into
IABTechLab:mainfrom
jwrosewell:split/4-client-resolve

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fourth of five stacked PRs decomposing #838 as requested in the #986 review. Stacks on #1045. Compare split/4-client-resolve to split/3-permissions to see only this PR's change.

Spec: docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md, the Tech Lab 2026-07-31 threat-model draft revised to the implemented state. The review feedback on the earlier draft of this endpoint is addressed point by point in this PR, and the spec's revision-record table maps each requirement to what v1 implements and what deliberately waits for the first vendor scheme.

Why this path matters

A client-cycle provider establishes the identifier via a browser round trip. The page script obtains or derives a value (for a real vendor, a signed envelope from the vendor's identity system), posts it to POST /_ts/api/v1/ec/resolve, and the provider verifies it before the edge mints it as the Edge Cookie. The first vendor integration this project targets works client-side by design, so this endpoint is on the series' critical path rather than deferred.

What this PR does

  • EdgeCookieProvider gains resolve_from_client with a no-op default, so server-side providers are untouched.
  • The resolve endpoint enforces the same rules as organic generation plus its own. The permission gate applies unchanged (the provider's complete declaration, not a hard-coded storage check). The request must carry an Origin on the publisher's domain (403 otherwise), and a text/plain or application/json body (415 otherwise, 413 over 64 KiB). A minted identifier must fit the global identifier bounds (400), and must not silently replace a different identity already on the request (409).
  • A minted identifier is persisted to the identity graph before the cookie is set, keyed by the provider's canonical form, so withdrawal reaches a client-set identity the same way it reaches an edge-minted one. With no graph available nothing is minted, matching the organic rule against phantom cookies, and a graph write failure answers 503 with no cookie. Every response the handler builds carries Cache-Control: no-store; a provider or configuration error propagates to the adapter as an error response.
  • The Edge Cookie stays HttpOnly. A non-HttpOnly companion marker cookie (ts-ecr=1, no identity content) tells the page script a resolve succeeded, fixing the earlier defect where the script gated on a cookie it could never read and therefore posted on every page view. The marker expires together with the Edge Cookie on withdrawal. A Rust test asserts the marker name and the demo's fixed word stay in sync with the page-script source.
  • The client-fixed demonstration provider is compiled only behind the client-fixed-demo cargo feature, and production builds reject the selection at startup. A fixed shared word is not an identity. Its minted cookie carries the registry code (cfix~an-ec), so even demo identities are provider-namespaced.
  • The Fastly adapter routes the endpoint with the same bot-gated identity graph as generation. The other adapters deliberately do not route it yet, matching identify and batch-sync, which need the same platform KV wiring those adapters lack (documented in the Spin adapter route list).
  • The full anti-replay reservation design from the spec is deliberately not in v1. It needs the real vendor envelope's unique id and session binding, and the spec retains it verbatim as the bar for that implementation.

How it was verified

Full local gate on this branch, all clean, including new endpoint tests for the origin, content-type, body-size, conflict, no-graph, and marker behaviors (the identifier-bound 400 and graph-write 503 paths are covered by unit tests of their helpers, not endpoint tests), and a round trip driving a client identifier through deferral, resolve, cookie set, and verbatim read-back. cargo test-fastly, cargo test-axum, cargo test-cloudflare, cargo test-spin, the integration parity suite, JS tests (npx vitest run), cargo fmt --check, and all six per-target clippy aliases.

References #778. Decomposes #838. Spec baseline from #986.

Produced with AI assistance under James Rosewell's direction, and flagged here so reviewers know to apply the usual scrutiny.

@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch 3 times, most recently from 88f96f8 to 82cd70f Compare August 25, 2026 10:51
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch from 82cd70f to 0217e09 Compare August 25, 2026 13:37
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch 6 times, most recently from 8a67a9f to eff9f74 Compare August 31, 2026 12:50
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Aug 31, 2026
The review of IABTechLab#1043 asked that spec changes land before the code that
implements them, so a divergence is a decision taken in review rather
than a ratification of something already merged. PRs IABTechLab#1043 to IABTechLab#1047
each carried the design document for their own step, and IABTechLab#1043 carried
a 607-line spec describing device providers, geo providers, the
permission model and the browser resolve endpoint, none of which is in
that PR.

Move all six series documents here, so this PR carries the complete
normative set and no code:

- 2026-07-30-pluggable-providers-design.md (from IABTechLab#1043)
- provider-code-registry.md (from IABTechLab#1043)
- 2026-07-30-permission-model-design.md (from IABTechLab#1045)
- 2026-07-30-client-cycle-ec-resolve-design.md (from IABTechLab#1046, later
  revised by IABTechLab#1047)
- 2026-07-30-integration-response-header-hook-design.md (from IABTechLab#1047)
- 2026-07-30-provider-migration-rollout-design.md (from IABTechLab#1047)

Each file is taken verbatim at the tip of the stack, so the later
revisions are preserved: the provider-switching continuity section, the
geo requires-signal floor, and the code-envelope paragraph IABTechLab#1047 added
to the client-cycle spec. The revision-record tables are unchanged. No
document's substance was edited.

The only edits are to this spec's own status line, which said the PR
adds one document and that the series specs land with IABTechLab#1047, and a
revision-record row recording the move.
@aram356

aram356 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

The sequencing discussion for this series is on #1084. This PR is superseded rather than rejected. The design in §3.6 is accepted and most of the provider work carries over onto the reordered base. See #1084.

…ider

First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.

- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
  The deprecated [ec] passphrase form still starts for one release
  cycle: it maps to provider = "hmac" with a deprecation warning, and a
  configuration carrying both forms is rejected. provider = "none"
  spells explicit statelessness. A configured block that is not the
  selected provider is rejected at startup, as is a block with no
  selector.
- Global identifier bounds enforced by core at mint, read-back, and
  cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
  cap. An identifier outside the bounds is rejected loudly, never
  rewritten, so the cookie value and the identity-graph key can never
  silently diverge.
- The identity graph is keyed by the provider's canonical form of the
  identifier (normalize_id_for_kv), so equivalent representations of
  one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
  access to the client IP, headers (including cookies), URL path, and
  query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
  provider, so a vendor provider lives in its own crate and core never
  names it. A selected provider the adapter does not inject fails the
  request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
  proceeding stateless.

Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.

Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.

The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.

Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.

is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
CodeQL's cleartext-logging query treats a call whose name contains
"passphrase" as a sensitive source, and because the method mutates the
Settings it belongs to, every later log line that prints anything from
Settings (store names, timeouts, header names) is reported as writing a
secret to a log. The passphrase itself is a Redacted<String> and none of
the flagged lines prints it. The method now describes what it does,
migrate_legacy_ec_layout, and its behavior is unchanged.
A reviewer raised a P1 against the pluggable Edge Cookie provider work:
three of the four adapters broke the provider contract that an
unavailable required service or an uninjected provider stops the
request.

The Axum, Cloudflare and Spin adapters each read the Edge Cookie context
with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`,
logged a warning and continued with `EcContext::default()`. A deployment
whose selected provider could not be built therefore came up and served
every request with no identity, silently. The Fastly adapter already
kept the report and answered with an error response.

`build_ec_context` on the three adapters now returns
`Result<EcContext, Report<TrustedServerError>>` and every call site
propagates it to that adapter's own `http_error`, the same helper Fastly
uses, so all four answer with the same status and shape.

The design this implements has the composition root check a selected
provider's needs once at startup rather than per request, so
`ensure_provider_available` was added to `ec/provider.rs` and is called
from `build_state_with_settings` on all four adapters (Fastly included,
so the rule is uniform). Building a provider reads no request data, so
a selection an adapter can never supply now fails when application state
is built, and the three adapters answer every route from their existing
`startup_error_router` instead of coming up. Statelessness, meaning no
`[ec] provider` selector or the explicit `"none"`, still passes and
still serves.

The widening question was checked rather than assumed.
`read_from_request_with_geo` can only fail from two places: the provider
build, and a `Cookie` header that is not valid UTF-8. A malformed cookie
value is dropped with a warning by `request_ec_id_if_allowed`, consent
parsing returns a value rather than a `Result`, and the geo lookup is
already swallowed by the adapter before the call, so no ordinary parse
problem reaches the error path and none is turned into a failed request.

Tests: each of the three adapters gains a route test proving an
uninjected provider fails at startup, and an in-crate test proving
`build_ec_context` returns the error rather than a default context.
Core gains a test that the startup check rejects an uninjected provider
and still allows statelessness both ways.

Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1)
`Settings::finalize_deserialized` runs derive validation before
`Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase`
field carries no `#[validate]` attribute of its own, so the advertised
32-byte minimum was only enforced on the new `[ec.providers.hmac]`
location. A configuration still on the old form could start with
`passphrase = "short"`, or with an empty value, and mint identifiers
from keying material the new location rejects.

The migration now calls `Ec::validate_passphrase` on the value it is
about to move, before it logs the deprecation warning and writes the
`[ec.providers.hmac]` block, and reports a configuration error naming
the minimum length and the new location.

Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives
`Settings::from_toml` with the `[ec]` section rewritten to the
deprecated form and proves a short value and an empty value are both
rejected, and that a passphrase of adequate length still migrates to
`provider = "hmac"` with the passphrase in the hmac block. Removing the
new check makes that test fail, so it tests the fix rather than the
surrounding code.

Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench)
`permissions.yaml` held a flat `rules:` map keyed on `FR` or `US/CA`,
while the answer for an unresolved place lived outside the file in the
`[geo] default_country` setting, and the GDPR country list and the US
privacy state list lived in the consent configuration. Three places
stated one policy, so a reader had to hold all three to know what a
visitor gets.

`rules:` is now a single tree. The top node stands for the whole world,
countries sit under it keyed by ISO 3166-1 alpha-2 code, and a
country's regions sit under it keyed by ISO 3166-2 code with no country
prefix. A node written as a plain string is the shorthand for a group
with nothing below it. Any node may name a `group` and a
`jurisdiction`, and a node that names neither inherits from the node
above, so the top node's required `group` and `jurisdiction` terminate
the inheritance and are also the answer for a visitor whose place
cannot be resolved. Matching stays most specific first and case
insensitive. The reserved words `group`, `jurisdiction` and
`permissions` cannot collide with a place code, because ISO codes are
at most three letters.

`jurisdiction` accepts `gdpr`, `us-state`, `non-regulated` and
`unknown`, one for each state `Jurisdiction` can represent, so a policy
owner cannot write something the consent code has no way of applying.
`us-state` carries no state code because the region naming it is the
state, and it is rejected on a country or at the top of the tree where
there is no region to name. The vocabulary is documented on
`Jurisdiction::from_policy_name`, next to the type it maps onto.

`detect_jurisdiction` now walks that same tree, so
`consent.gdpr.applies_in` and `consent.us_states.privacy_states` retire
along with `GdprConfig` and `UsStatesConfig`. The shipped file
reproduces both compiled defaults, because the 31 GDPR countries (27
EU, 3 EEA, the UK) inherit `gdpr` from the top node and stay one-line
shorthands, and the 20 US privacy states are region children of `US`
carrying `jurisdiction: us-state`, each with the same `us-opt-out`
group as the country node so no permission changed. A new test asserts
both lists verbatim. `US` and `AU` carry `non-regulated`, matching what
the retired lists gave them. One behavior does change, in that a
located country with no rule of its own now inherits the top node's
`gdpr` rather than resolving `NonRegulated`, which is the same
treatment its permission baseline already received and is the stricter
of the two.

The file header teaches the format to a policy owner, covering the
tree, the shorthand, the required top node, the inheritance rule, the
accepted jurisdiction values, the case rules, and why a place code can
never be a reserved word. The worked example in the header is proven by
a test rather than asserted.

Trusted Server states no jurisdiction's law. The deployer states the
policy and this file is where they write it, and the software only
encodes what they wrote.

Retire the [geo] default_country setting

The permission baseline for a request the geo provider leaves
unmatched, and the jurisdiction its consent gates apply, both now come
from the top of the `permissions.yaml` rules tree, so the deployer
setting that used to supply them has nothing left to say. `GeoConfig`
loses the field, `validate_default_country` is replaced by
`validate_permission_policy`, which parses the compiled policy at
startup so a top node missing `group` or `jurisdiction` is a
configuration error rather than a panic on the first lookup, and the
startup log now names the policy's top node and its jurisdiction
instead of a configured country.

`assemble_permissions` and `storage_acquisition` no longer take
`Settings`, and `PermissionMaps::resolve_with`, `baseline` and
`resolve` lose their default country and region arguments. A geo lookup
failure reaches the requires-signal floor through the new
`PermissionMaps::floor_with` rather than by passing no default, so its
behavior is unchanged and it still never consults the tree.

`ConsentPipelineInput` gains `default_jurisdiction`, the value to apply
when no location resolved. It replaces the synthetic `GeoInfo` the EC
context used to build from `default_country` to stop the consent gates
failing closed where a jurisdiction had been declared. A failed lookup
passes `Unknown`, so it still fails closed.

Every fixture and test that set `default_country` is rewritten rather
than dropped. Tests that used `default_country = "US/CA"` to obtain a
granted baseline now place the request in that state through the geo
seam, which is a truer test because it exercises the lookup path. The
adapters, the integration fixture and `trusted-server.example.toml`
lose the key, and their comments now point at the policy file. Two
consequences are worth flagging for review. The Docker integration
fixture used `US/CA` so its loopback requests, which Viceroy does not
geolocate, got a granted baseline, and those requests now resolve at
the shipped top node (`gdpr-eu`), where storage requires a signal. The
same applies to `scripts/template-cache-local-test.sh`, whose harness
can no longer switch jurisdiction through configuration.

Trusted Server states no jurisdiction's law. Moving the default into
the policy file puts the deployer's whole statement in one reviewable
place, and the software only encodes what they wrote.
…tion

The EC lifecycle scenarios relied on the retired [geo] default_country to give the loopback client a US/CA baseline. Viceroy supports a geolocation mapping, so the template now maps the loopback client to California, United States, and the fixture selects the platform geo provider. The scenarios keep exercising the US state opt-out machinery through the genuine lookup path rather than through a setting that pretended a place. Verified by running test_ec_lifecycle_fastly against the release wasm with the generated config, 1 passed.
The harness relied on the retired [geo] default_country to run the auction without a consent signal, so CI's template-cache assertions failed once the retirement landed: the auction failed closed under the rules tree's GDPR top node and the served document carried no seam payload. The harness now appends a Viceroy geolocation block mapping the loopback client to US/CA and uncomments the platform geo selector in its generated config, so the auction runs under the US state opt-out rules through the genuine lookup path. Verified locally in both modes: esi 20 of 21 assertions pass and inline 7 of 8, with the single failure being the socket timing probe returning no numeric timings under Git Bash on Windows, an environment artifact CI's Linux runner does not share, and every assertion that failed in CI passing.
The docs branch's [geo] comment block contains a prose sentence that begins with the same characters as the commented selector, so the unanchored replace stripped the comment marker from prose and produced invalid TOML on the branches that carry it. The replace now matches the exact selector line between newlines.
The previous anchoring edit was corrupted in transit and left a Python string literal broken across lines, so the harness's config generation failed with a syntax error in CI. The replacement is now written with explicit newline characters that no quoting layer can mangle, and it was verified by executing the harness's own embedded generator against both the permissions branch's example config and the stack tip's, with the output parsing as valid TOML, the selector active, and the prose comment untouched.
The shipped policy was conservative to the point of looking wrong: with
a fully consenting TCF record in an EU country only 11 of 64 Data Uses
could ever be set, because every Data Use without a TCF mapping was
denied. The policy now models common practice so a reader evaluates the
mechanism rather than the data.

Service operations, security, fraud prevention and legal obligations
are granted in every group, matching TCF special purposes 1 and 2 where
they touch delivery. The modeled ad-tech Data Uses each map to a TCF
purpose, and a purpose may now grant a list of Data Uses (frequency
capping and negative targeting under purpose 2, delivery and viewability
reporting under purpose 7, campaign insights under purpose 9, and the
personalization Data Uses under purpose 6), which required letting
signals.tcf purposes carry lists, with a startup error when a Data Use
appears under more than one purpose. Umbrella Data Uses stay denied so
every grant is named at the specific level. The US group grants the
ad-tech set and the sale and sharing disclosures until an opt-out, and
the opt-out revoke list is narrowed from all to the US state scope,
being sale, sharing, targeted advertising, and device storage so an
opted-out visitor still gets no Edge Cookie and no identifier sharing,
which the consent precedence tests pin. The file header example also
now uses the shipped group names (us-notice was never defined).

Full consent in an EU country now sets 42 of 64 Data Uses, a US state
visitor gets 45 by default and 38 after a Global Privacy Control
opt-out. All 2400 core tests pass.
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch from bb76eb5 to 496123e Compare September 2, 2026 14:31
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Sep 2, 2026
The review of IABTechLab#1043 asked that spec changes land before the code that
implements them, so a divergence is a decision taken in review rather
than a ratification of something already merged. PRs IABTechLab#1043 to IABTechLab#1047
each carried the design document for their own step, and IABTechLab#1043 carried
a 607-line spec describing device providers, geo providers, the
permission model and the browser resolve endpoint, none of which is in
that PR.

Move all six series documents here, so this PR carries the complete
normative set and no code:

- 2026-07-30-pluggable-providers-design.md (from IABTechLab#1043)
- provider-code-registry.md (from IABTechLab#1043)
- 2026-07-30-permission-model-design.md (from IABTechLab#1045)
- 2026-07-30-client-cycle-ec-resolve-design.md (from IABTechLab#1046, later
  revised by IABTechLab#1047)
- 2026-07-30-integration-response-header-hook-design.md (from IABTechLab#1047)
- 2026-07-30-provider-migration-rollout-design.md (from IABTechLab#1047)

Each file is taken verbatim at the tip of the stack, so the later
revisions are preserved: the provider-switching continuity section, the
geo requires-signal floor, and the code-envelope paragraph IABTechLab#1047 added
to the client-cycle spec. The revision-record tables are unchanged. No
document's substance was edited.

The only edits are to this spec's own status line, which said the PR
adds one document and that the series specs land with IABTechLab#1047, and a
revision-record row recording the move.
The file header called itself a shipped demo policy, which reads as
Trusted Server shipping a policy position. It never does. The builder
of a deployment chooses the permissions.yaml compiled into their image,
an operator overlay is the recorded follow-on, and the visitor's
signals decide the rest at runtime. This copy is the repository's test
policy, and the header now says exactly that.
No policy ever ships with Trusted Server, and the repository's
permissions files are samples, not fixtures owned by any one consumer.
They now live in config/permissions, each carrying a display name at
the top (the repository sample is config/permissions/vanilla.yaml,
named Vanilla), so a tool listing the directory can present them
readably, and whoever builds, configures, or inspects makes an explicit
choice of file. The compiled-in default follows the move, and concrete
path references in the example TOML and guides are updated. All 2400
core tests pass.
A single page at tools/permissions-inspector answers the question a
policy owner has about a permissions file: for a visitor in a given
place, with given consent signals, which Data Uses are set? The page
walks three steps, the policy baseline, the visitor's input (location,
TCF consent, and Global Privacy Control on their own tabs), and what
the application layer gets, with a plain-language reason per Data Use
and balloon help quoting each Data Use's definition from the IAB Tech
Lab Privacy Taxonomy.

Step 3 is computed by trusted-server-core itself, compiled to
WebAssembly by the wrapper crate in tools/permissions-inspector/wasm
and built by scripts/build-inspector-wasm.sh, which a new workflow runs
in CI. The wrapper exposes the production path: raw TC, GPP and US
Privacy strings and GPC go through build_context_from_signals, the
policy resolves through assemble_permissions, and pasted files are
validated by PermissionMaps::from_yaml with the server's own error
messages. The page states the trusted-server version, branch, commit
and date the engine was built from, with source links, and reports how
many Data Uses the page's explanatory JS mirror agrees on so drift is
visible.

The page lists the repository's sample permissions from
config/permissions through a manifest the build script writes, labeled
by each file's name line, and says plainly that no policy ever ships
with Trusted Server. Hosting the page through GitHub Pages is noted in
the README as the follow-on.
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch from 496123e to 78a16e8 Compare September 2, 2026 16:32
Comment thread .github/workflows/inspector.yml Fixed
CI failed with permission denied because the executable bit was never
recorded in git, the usual Windows-authored-script gap.
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch from 78a16e8 to eff3dc1 Compare September 2, 2026 16:39
The code-scanning bot flagged the new workflow for carrying the default
GITHUB_TOKEN permissions. The job only checks out and builds, so it
reads contents and nothing else.
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch from eff3dc1 to 65839fd Compare September 2, 2026 16:55
The doc comment above the include_str still said the file lives at the
repository root after the move to config/permissions/vanilla.yaml.
Fourth slice of the PR 838 decomposition. A client-cycle Edge Cookie
provider defers at the edge and lets the page derive the identifier in
the browser; the page posts it to POST /_ts/api/v1/ec/resolve and the
provider verifies it before the edge mints it as the Edge Cookie:

- EdgeCookieProvider gains resolve_from_client with a no-op default, so
  server-side providers are untouched. ClientResolveInput carries the
  posted payload plus the request's resolved permissions and consent.
- The resolve endpoint enforces the same rules as organic generation
  and several of its own. The permission gate applies unchanged. The
  request must carry an Origin on the publisher's domain (an
  identity-setting POST must not be drivable from a foreign page) and a
  text/plain or application/json body. A minted identifier must fit the
  identifier bounds (400), must not silently replace a different
  identity already on the request (409), and is persisted to the
  identity graph before the cookie is set, keyed by the provider's
  canonical form, so withdrawal reaches a client-set identity the same
  way it reaches an edge-minted one. With no graph available nothing is
  minted, matching the organic rule against phantom cookies; a graph
  write failure is 503. Every response carries Cache-Control: no-store.
- The Edge Cookie stays HttpOnly. A non-HttpOnly companion marker
  cookie (ts-ecr=1, no identity content) tells the page script a
  resolve succeeded, so it does not re-post on every page view; the
  marker expires together with the Edge Cookie on withdrawal. A Rust
  test asserts the marker name and the demo's fixed word stay in sync
  with the page script source.
- The client-fixed demonstration provider (fixed shared word,
  verify-before-mint) is compiled only behind the trusted-server-core
  client-fixed-demo cargo feature. Production builds reject the
  selection at startup: a fixed shared word is not an identity.
- The Fastly adapter routes the endpoint and passes the same bot-gated
  identity graph as generation, so unrecognized clients cannot mint
  through resolve either. The other adapters deliberately do not route
  it yet, matching identify and batch-sync, which need the same
  platform KV wiring those adapters lack.

The design spec for this slice lives at
docs/superpowers/specs/2026-07-30-client-cycle-ec-resolve-design.md,
the 2026-07-31 threat-model draft revised to the implemented state,
with the deferred reservation design retained verbatim as the bar for
the first vendor scheme.
The spec used em dashes throughout; they are replaced with plain
punctuation. The resolve handler's doc claimed every response carries
Cache-Control: no-store, but a provider or configuration error leaves
through the error path to the adapter's own response, so the doc now
says every response the handler builds.
`ec/provider.rs` documents that core checks every provider response header
against its reserved surface before applying it, so a provider may set its
own cookies and headers but cannot reach into the surface core manages.
The organic mint path honours that in
`EcContext::generate_with_provider`. The resolve endpoint applied provider
headers with no check at all, on both the 204 path where the provider
mints nothing and the 200 path where it does.

That let a browser-side provider set the managed `ts-ec` cookie directly
and bypass the identifier bounds check, the conflict check and the
row-before-cookie rule that sit a few lines below it. Removing the new
check and re-running the tests shows the 204 response going out as
`204` with `set-cookie: ts-ec=forged-value; Path=/` and no identity-graph
row behind it, which is an external reviewer's own finding on this stack,
answered on the Edge Cookie provider branch and reintroduced here. His
words were that a provider can set the managed cookie "including when it
returns no identifier", which is exactly that path.

The check is the same call the organic path makes, placed immediately
after `resolve_from_client` and before the identifier is read, so one
check covers both paths for the same reason the organic one sits there: a
provider can return headers with no identifier at all. A breach fails the
request rather than producing a status, matching the organic path and the
module's existing rule that a provider error propagates to the adapter's
error response, because a provider reaching into the reserved surface has
broken its contract rather than sent a bad request.

Tested on both paths: a managed `ts-ec` cookie with nothing minted, and an
`x-ts-ec` header on the minted path.
The two sites in the resolve handler had the same defect the EC
finalization loop had: `HeaderMap::insert` replaces every existing value
for a header name, so a provider setting more than one cookie kept only
the last, and a provider setting a header this handler already wrote
silently replaced it.

On this path the replaced header is core's own rather than an origin's,
and the one that matters is `Cache-Control: no-store`. Every response the
handler builds carries it, because an identity response must never be
cached, and an inserting write let a provider drop it. Removing the fix
and re-running the new test shows both: the provider's first cookie gone,
and `no-store` replaced by the provider's `max-age=600`.

Both sites now go through the shared `apply_provider_response_headers`,
which carries the rule and is the same helper EC finalization uses, so the
two paths cannot drift.
`ProviderCode::new` is fallible and returns `Option<ProviderCode>`, but
this test provider passed its result straight back from `code()`, which
returns `ProviderCode`. The test module therefore did not compile.

Nothing caught it because the only checks run against this branch were
`cargo check`, which builds the library and not the `#[cfg(test)]`
modules, so the branch looked healthy while its own tests could not be
built.

The macro is the established form for a fixed code and is already used
by the neighbouring test provider in this same file. It resolves the
code in a `const` block, so a malformed code fails the build rather than
the request, and there is no `Option` left to unwrap.

Addresses: crates/trusted-server-core/src/ec/resolve.rs, where the
`ResolveHeaderProvider` test double could not compile.
The Edge Cookie context carried the raw injected provider so the resolve
endpoint could rebuild it later. That slot is gone, so the context now
carries the provider the composition root threaded, which is the one seam
section 3.6 specifies.
…e resolve cycle on a provider switch

Two review findings from Christian Pavilonis on IABTechLab#1084.

The resolve endpoint authorized any origin that was a suffix match on the
publisher domain, and it also accepted an http:// origin and discarded the
port before comparing. The Edge Cookie is scoped to the parent domain, so a
delegated or compromised sibling subdomain could fix an identity that lands
on the apex and every sibling with it. Authorization is now a comparison of
the whole serialized origin, being the scheme, the lowercased host and the
effective port. The default accepted origin is https://<publisher.domain>
and nothing else, and [ec] resolve_allowed_origins adds further exact
origins for a publisher whose pages are served elsewhere.

The resolved marker is not namespaced by the provider code the way the
cookie value, the identity-graph key and withdrawal are, so after a switch
between client-cycle providers it outlived the identity it was set for and
the new provider's page script would skip the resolve it should perform.
Core now expires the marker whenever the request carries an identifier the
selected provider does not own, which does not depend on any vendor page
script comparing a marker value correctly.
The client resolve endpoint compared the request Origin and each allowed origin as lowercased strings, which matched what browsers send but was not the RFC's rule, so a configured https://www.example.com:443 could never match the https://www.example.com a browser serializes. origins_match now parses both sides as RFC 6454 §6.1 serialized origins and compares the scheme, host and port triple of §4 under the §5 same-origin test, with a missing port standing for the scheme's default and the opaque null origin never matching. A unit test covers the equal and unequal cases, and the settings and example config wording cite the RFC.
The client-fixed page script posted the known word whenever the resolved marker was absent, relying on the server's gate alone. A page module is treated like any other provider: it declares the permissions it requires and checks them against the resolved state the server hands the page before it does anything. The script now declares necessary.operations.storage, the same Data Use the server-side provider requires, waits on tsjs.whenPermissions() so the check works whether the state arrived at head open or at the body seam, and posts only when that permission is set. With no permission state on the page it does not post. The server still enforces the same gate on the resolve endpoint, so this is the page's half of one decision rather than a second policy.

The Rust test that keeps the script's constants in step with the provider now also asserts that REQUIRED_PERMISSION matches Permission::StoreOnDevice.as_str(). Vitest covers the permission unset, no state, and state-arrives-later cases.
@jwrosewell
jwrosewell force-pushed the split/4-client-resolve branch from 65839fd to d9c2439 Compare September 2, 2026 19:39
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.

3 participants