Add the client-set Edge Cookie value path - #1046
Open
jwrosewell wants to merge 88 commits into
Open
Conversation
This was referenced Aug 19, 2026
jwrosewell
force-pushed
the
split/4-client-resolve
branch
3 times, most recently
from
August 25, 2026 10:51
88f96f8 to
82cd70f
Compare
jwrosewell
force-pushed
the
split/4-client-resolve
branch
from
August 25, 2026 13:37
82cd70f to
0217e09
Compare
jwrosewell
force-pushed
the
split/4-client-resolve
branch
6 times, most recently
from
August 31, 2026 12:50
8a67a9f to
eff9f74
Compare
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.
Collaborator
jwrosewell
force-pushed
the
split/4-client-resolve
branch
from
September 1, 2026 15:34
eff9f74 to
5552519
Compare
jwrosewell
force-pushed
the
split/4-client-resolve
branch
5 times, most recently
from
September 1, 2026 23:24
670b0e8 to
bb76eb5
Compare
…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
force-pushed
the
split/4-client-resolve
branch
from
September 2, 2026 14:31
bb76eb5 to
496123e
Compare
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
force-pushed
the
split/4-client-resolve
branch
from
September 2, 2026 16:32
496123e to
78a16e8
Compare
CI failed with permission denied because the executable bit was never recorded in git, the usual Windows-authored-script gap.
jwrosewell
force-pushed
the
split/4-client-resolve
branch
from
September 2, 2026 16:39
78a16e8 to
eff3dc1
Compare
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
force-pushed
the
split/4-client-resolve
branch
from
September 2, 2026 16:55
eff3dc1 to
65839fd
Compare
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
force-pushed
the
split/4-client-resolve
branch
from
September 2, 2026 19:39
65839fd to
d9c2439
Compare
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.
Fourth of five stacked PRs decomposing #838 as requested in the #986 review. Stacks on #1045. Compare
split/4-client-resolvetosplit/3-permissionsto 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
EdgeCookieProvidergainsresolve_from_clientwith a no-op default, so server-side providers are untouched.Originon the publisher's domain (403 otherwise), and atext/plainorapplication/jsonbody (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).Cache-Control: no-store; a provider or configuration error propagates to the adapter as an error response.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.client-fixeddemonstration provider is compiled only behind theclient-fixed-democargo 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.identifyandbatch-sync, which need the same platform KV wiring those adapters lack (documented in the Spin adapter route list).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
400and graph-write503paths 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.