Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
15af0ec
Add the client-set Edge Cookie value path
jwrosewell Aug 19, 2026
92a9594
Tidy the client-cycle spec prose and state the no-store rule precisely
jwrosewell Aug 27, 2026
a80c8a2
Hold the resolve endpoint to the reserved response-surface contract
jwrosewell Aug 29, 2026
dc60823
Accumulate provider response headers on the resolve endpoint too
jwrosewell Aug 29, 2026
73ea970
Build the resolve test provider code through the macro that cannot fail
jwrosewell Aug 31, 2026
5413ccb
Documentation and house-style cleanup for the client-resolve PR
jwrosewell Aug 31, 2026
ead75e2
Align the header-accumulation test with the reserved cache-control su…
jwrosewell Aug 31, 2026
678b322
Reach the resolve endpoint's provider through the single seam
jwrosewell Aug 31, 2026
ee07418
Authorize the client resolve endpoint by exact origin, and restart th…
jwrosewell Sep 1, 2026
e2a7d49
Compare resolve origins by the RFC 6454 same-origin test
jwrosewell Sep 1, 2026
93e3d69
Make the demo page script check its required permission before posting
jwrosewell Sep 1, 2026
0a54cf3
Merge the permission signal work from split/3
jwrosewell Sep 14, 2026
8c36bba
Merge the follow-up commits from split/3
jwrosewell Sep 14, 2026
69e7d57
Merge main into client resolve through split/3
jwrosewell Sep 14, 2026
2c34be4
Merge the Edge Cookie review follow-ups through split/3
jwrosewell Sep 14, 2026
b7dd13f
Merge the permissions inspector fix from split/3
jwrosewell Sep 14, 2026
e17090b
Merge the permissions sample rename from split/3
jwrosewell Sep 14, 2026
60bf324
Name the client-fixed demo Edge Cookie provider client_fixed
jwrosewell Sep 15, 2026
ea716f5
Merge the per-provider [ec] tables and the permission signal rename f…
jwrosewell Sep 15, 2026
eca58fe
Merge main into the client-set Edge Cookie value path
jwrosewell Sep 25, 2026
077da8f
Merge the test state fix from split/3
jwrosewell Sep 25, 2026
7d53894
Merge the fixture jurisdiction fix from split/3
jwrosewell Sep 25, 2026
ffe1b44
Merge the withdrawal test fix from split/3
jwrosewell Sep 25, 2026
20af33c
Merge split/3-permissions at 511db9b3a into split/4-client-resolve
jwrosewell Oct 6, 2026
c4722aa
Refuse a resolve_allowed_origins entry that is not a bare origin
jwrosewell Oct 6, 2026
c87a986
Answer a resolve with the module the request selected
jwrosewell Oct 6, 2026
3b30859
Stop refusing the client-fixed spelling by name
jwrosewell Oct 6, 2026
8ae43d1
Write the resolved Edge Cookie with set_ec_cookie
jwrosewell Oct 6, 2026
7de2f18
Fold the Edge Cookie resolve tests that repeat one arrange
jwrosewell Oct 6, 2026
bbda299
Bring the resolve tests into line with what the endpoint does
jwrosewell Oct 6, 2026
c91b8c6
Fold the demonstration module's resolve_from_client tests into one table
jwrosewell Oct 6, 2026
b63eabc
Merge split/3-permissions at 8f6047e94 into split/4-client-resolve
jwrosewell Oct 6, 2026
ff56a5f
The client resolve path says module where it said provider
jwrosewell Oct 6, 2026
17fd681
Thread the selected module into the conflict test's context
jwrosewell Oct 6, 2026
e3aeeba
Restore the icu_provider crate name in the inspector's lockfile
jwrosewell Oct 6, 2026
9a37728
Merge split/3-permissions at fd51fe830 into split/4-client-resolve
jwrosewell Oct 7, 2026
6b9f52b
Merge split/3-permissions at c0a0f3a81 into split/4-client-resolve
jwrosewell Oct 7, 2026
59660c4
Merge split/3-permissions at 613cd1831 into split/4-client-resolve
jwrosewell Oct 7, 2026
e7623de
Merge split/3-permissions at 0afd715c5 into split/4-client-resolve
jwrosewell Oct 8, 2026
a70e9d2
Merge split/3-permissions into split/4-client-resolve
Jamesr51d Oct 13, 2026
f4b66f1
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 15, 2026
7ab5302
The demo Edge Cookie module owns the fixed word it creates
jwrosewell Oct 16, 2026
adb7983
Merge split/4-client-resolve at 7ab530225 into split/4-client-resolve
jwrosewell Oct 16, 2026
39547cb
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 16, 2026
079fc72
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 16, 2026
009dc79
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 16, 2026
74b5466
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 18, 2026
15dc6fc
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 18, 2026
48ff9bc
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 18, 2026
3990af8
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 19, 2026
5afddea
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 19, 2026
b9481c1
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 19, 2026
5744b2e
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 19, 2026
6fedbbf
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 28, 2026
2b7ad99
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 28, 2026
3dc4f71
The resolve tests' services carry the sample policy
jwrosewell Oct 28, 2026
8a8b32b
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 28, 2026
5fa77c2
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 28, 2026
15a35cf
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 28, 2026
04d9284
Merge split/3-permissions into split/4-client-resolve
jwrosewell Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ deployment selects an implementation and the core stays neutral:

| Capability | Trait | Selector | Built-in (core) | Vendor / host crates |
| --------------------- | ----------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------- |
| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/<vendor>` |
| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | HMAC, client_fixed (opt-in, no default) | `crates/edgecookie/<vendor>` |
| Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | User-Agent only (default) | `crates/device/<vendor>` |
| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | Disabled, no location (default) | `crates/geo/<vendor>` |
| Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | None, and with no module every permission stays at its country and region baseline | `crates/permission-signal/<scheme>` |
Expand Down
36 changes: 34 additions & 2 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
//! | GET | `/_ts/set-tester` | [`handle_set_tester`] |
//! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] |
//! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] |
//! | POST | `/_ts/api/v1/ec/resolve` | [`handle_ec_resolve`] |
//! | POST | `/auction` | [`handle_auction`] |
//! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] |
//! | GET | `/first-party/click` | [`handle_first_party_click`] |
Expand Down Expand Up @@ -116,6 +117,7 @@ use trusted_server_core::ec::kv::KvIdentityGraph;
use trusted_server_core::ec::module::request_module;
use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module};
use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::ec::resolve::handle_ec_resolve;
use trusted_server_core::ec::{EcContext, EidSyncSource};
use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError};
use trusted_server_core::http_util::is_navigation_request;
Expand All @@ -128,8 +130,6 @@ use trusted_server_core::platform::{
ClientInfo, GeoInfo, PlatformKvStore, PolicyOrigin, RuntimeServices, StoreName,
build_geo_module,
};
use trusted_server_device_fastly::FastlyHostSignals;

use trusted_server_core::proxy::{
AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click,
handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign,
Expand All @@ -146,6 +146,7 @@ use trusted_server_core::request_signing::{
use trusted_server_core::settings::{ProxyAssetRoute, Settings};
use trusted_server_core::settings_data::{DEFAULT_CONFIG_STORE_ID, get_settings_from_config_store};
use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester};
use trusted_server_device_fastly::FastlyHostSignals;

use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware};
use crate::platform::{
Expand Down Expand Up @@ -797,6 +798,12 @@ async fn run_named_route(
}
NamedRouteHandler::SetTester => handle_set_tester(&state.settings),
NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings),
NamedRouteHandler::EcResolve => {
// The resolve endpoint persists the identity-graph row before
// creating, so it takes the same bot-gated graph as generation: an
// unrecognized client gets no graph, and therefore no new Edge Cookie.
handle_ec_resolve(&state.settings, req, &ec.ec_context, ec.kv_graph.as_ref())
}
NamedRouteHandler::Auction => {
ec.ec_context.set_eid_sync_source(EidSyncSource::Auction);
let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?;
Expand Down Expand Up @@ -1223,6 +1230,7 @@ enum NamedRouteHandler {
Identify,
SetTester,
ClearTester,
EcResolve,
Auction,
PageBids,
FirstPartyProxy,
Expand Down Expand Up @@ -1337,6 +1345,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[
primary_methods: &[Method::GET],
handler: NamedRouteHandler::ClearTester,
},
NamedRoute {
path: "/_ts/api/v1/ec/resolve",
primary_methods: &[Method::POST],
handler: NamedRouteHandler::EcResolve,
},
NamedRoute {
path: "/auction",
primary_methods: &[Method::POST],
Expand Down Expand Up @@ -2461,6 +2474,25 @@ mod tests {
);
}

#[test]
fn dispatch_ec_resolve_routes_to_resolve_handler() {
// Parity guard: POST /_ts/api/v1/ec/resolve must reach the resolve
// handler, not the publisher fallback or a router-level 405. The test
// request carries no Origin, so the handler's own origin guard answers
// 403, proving the request was handled here rather than proxied to
// the publisher origin (which would error without a live backend).
let router = test_router();
let response =
block_on(router.oneshot(empty_request(Method::POST, "/_ts/api/v1/ec/resolve")))
.expect("should route request");

assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"POST ec/resolve should reach the resolve handler's origin guard, not the publisher"
);
}

#[test]
fn dispatch_fallback_attaches_ec_finalize_state() {
// The publisher fallback must thread EC finalize state to the entry
Expand Down
3 changes: 2 additions & 1 deletion crates/trusted-server-adapter-spin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ fn publisher_fallback_methods() -> [Method; 7] {
// reaches the publisher origin rather than a router-level 405.
//
// The EC API routes that the Fastly entry point registers — POST
// `/_ts/api/v1/batch-sync`, GET/OPTIONS `/_ts/api/v1/identify` — are
// `/_ts/api/v1/batch-sync`, GET/OPTIONS `/_ts/api/v1/identify`, POST
// `/_ts/api/v1/ec/resolve`, are
// intentionally absent here, matching the Axum and Cloudflare adapters: those
// handlers require a platform KV `ec_store` (and, for batch-sync, a partner
// registry and rate limiter) that the portability adapters do not yet wire.
Expand Down
3 changes: 3 additions & 0 deletions crates/trusted-server-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ uuid = { workspace = true, features = ["js"] }
chrono = { workspace = true, features = ["wasmbind"] }

[features]
# Compiles in the `client_fixed` demonstration Edge Cookie module. Off by
# default so production builds cannot select a fixed shared word as identity.
client-fixed-demo = []
default = []
# Exposes test-only constructors (e.g. `IntegrationRegistry::from_request_filters`)
# so downstream crates can build registries with stub integrations in their tests.
Expand Down
15 changes: 15 additions & 0 deletions crates/trusted-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report<Tr
let plan = crate::auction::compile_auction_plan(settings)?;
validate_enabled_integrations(settings, &plan, false)?;
PartnerRegistry::validate_config_for_deploy(&settings.ec.partners)?;
settings.ec.validate_resolve_allowed_origins()?;
Ok(())
}

Expand Down Expand Up @@ -1425,6 +1426,20 @@ gam_network_id = "99999"
);
}

#[test]
fn app_config_new_rejects_a_resolve_allowed_origin_that_is_not_bare() {
let mut settings = valid_settings();
settings.ec.resolve_allowed_origins = vec!["https://www.example.com/".to_owned()];

let err = TrustedServerAppConfig::new(settings)
.expect_err("should refuse at push an entry the settings refuse at load");

assert!(
err.to_string().contains("`https://www.example.com/`"),
"error should name the entry: {err:?}"
);
}

#[test]
fn app_config_new_rejects_invalid_non_secret_settings() {
let mut settings = valid_settings();
Expand Down
6 changes: 6 additions & 0 deletions crates/trusted-server-core/src/constants.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
use http::header::HeaderName;

pub const COOKIE_TS_EC: &str = "ts-ec";
/// Non-`HttpOnly` companion to [`COOKIE_TS_EC`], set when a client-cycle
/// resolve succeeds. It carries no identity (the value is `1`); it only lets
/// the page script see that an Edge Cookie exists, which the `HttpOnly` cookie
/// itself cannot, so the script does not re-post on every page view.
pub const COOKIE_TS_EC_RESOLVED: &str = "ts-ecr";

/// Short-lived signed proof that the current EC row has every pull-partner UID.
pub const COOKIE_TS_EC_PULL_COMPLETE: &str = "ts-ec-pull-complete";
/// Cookie written by the Trusted Server JS SDK containing a standard-base64-encoded
Expand Down
188 changes: 110 additions & 78 deletions crates/trusted-server-core/src/ec/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
use edgezero_core::body::Body as EdgeBody;
use http::{HeaderValue, Response, header};

use crate::constants::COOKIE_TS_EC;
use crate::constants::{COOKIE_TS_EC, COOKIE_TS_EC_RESOLVED};
use crate::settings::Settings;

/// Maximum age for the EC cookie (1 year in seconds).
Expand Down Expand Up @@ -125,6 +125,61 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response<EdgeBody>)
log::warn!("Skipping EC cookie expiry Set-Cookie: invalid header value: {e}");
}
}
// Expire the resolved marker together with the Edge Cookie, so a page
// whose visitor later re-establishes the permission can resolve again.
expire_ec_resolved_marker(settings, response);
}

/// Expires the resolved marker on its own, leaving the Edge Cookie alone.
///
/// The marker records only that some client-cycle module resolved in the
/// past. It is not namespaced by the module code the way the cookie value,
/// the identity-graph key and withdrawal are, so on a switch from one
/// client-cycle module to another the marker outlives the identity it was
/// set for. The new module's page script would then see the marker, skip the
/// resolve it should perform, and the visitor would sit with no identity
/// rather than a restarted one. Expiring the marker whenever the incoming
/// value is not one the selected module owns restarts the cycle without
/// depending on any vendor page script comparing a marker value correctly.
pub fn expire_ec_resolved_marker(settings: &Settings, response: &mut Response<EdgeBody>) {
let marker = format!(
"{COOKIE_TS_EC_RESOLVED}=; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=0",
settings.publisher.ec_cookie_domain(),
);
match HeaderValue::from_str(&marker) {
Ok(val) => {
response.headers_mut().append(header::SET_COOKIE, val);
}
Err(e) => {
log::warn!("Skipping resolved-marker expiry Set-Cookie: invalid header value: {e}");
}
}
}

/// Sets the `non-HttpOnly` resolved-marker cookie alongside a client-cycle Edge
/// Cookie.
///
/// The Edge Cookie itself is `HttpOnly`, so the page script cannot see it and
/// would otherwise post to the resolve endpoint on every page view. The marker
/// carries no identity (its value is `1`); it only signals that a resolve
/// succeeded. It shares the Edge Cookie's `Domain`, `Path`, `Secure`,
/// `SameSite`, and lifetime, and is expired together with it by
/// [`expire_ec_cookie`].
pub(crate) fn set_resolved_marker_cookie(settings: &Settings, response: &mut Response<EdgeBody>) {
let cookie = format!(
"{COOKIE_TS_EC_RESOLVED}=1; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}",
settings.publisher.ec_cookie_domain(),
);
match HeaderValue::from_str(&cookie) {
Ok(val) => {
response.headers_mut().append(header::SET_COOKIE, val);
}
Err(e) => {
// Unreachable in practice: the value is a constant and the domain
// comes from operator-trusted configuration.
log::warn!("Skipping resolved-marker Set-Cookie: invalid header value: {e}");
}
}
}

#[cfg(test)]
Expand Down Expand Up @@ -194,91 +249,68 @@ mod tests {
);
}

/// Every value in the identifier alphabet is written verbatim, whatever
/// module made it, with the standard security attributes.
#[test]
fn set_ec_cookie_appends_header() {
let settings = create_test_settings();
let mut response = empty_response();
set_ec_cookie(&settings, &mut response, TEST_EC_ID);

let cookie_header = response
.headers()
.get(header::SET_COOKIE)
.expect("should have Set-Cookie header");
let cookie_str = cookie_header.to_str().expect("should be valid UTF-8");

assert_eq!(
cookie_str,
create_ec_cookie(&settings, TEST_EC_ID),
"should match create_ec_cookie output"
);
}

#[test]
fn set_ec_cookie_rejects_disallowed_chars_outright() {
// Rejection, never rewriting: an identifier outside the alphabet must
// not produce a cookie at all, so the cookie value and the identity
// graph key can never silently diverge.
let settings = create_test_settings();
let mut response = Response::new(EdgeBody::empty());
set_ec_cookie(
&settings,
&mut response,
"evil;injected
foo=bar",
);
assert!(
response.headers().get(header::SET_COOKIE).is_none(),
"an identifier outside the alphabet should set no cookie"
);
}

#[test]
fn create_ec_cookie_preserves_well_formed_id() {
let settings = create_test_settings();
let id = "abc123def0123456789abcdef0123456789abcdef0123456789abcdef01234567.xk92ab";
let result = create_ec_cookie(&settings, id);
let value = result
.strip_prefix(&format!("{COOKIE_TS_EC}="))
.and_then(|s| s.split_once(';').map(|(v, _)| v))
.expect("should have cookie value portion");

assert_eq!(value, id, "should not modify a well-formed EC ID");
}

#[test]
fn set_ec_cookie_rejects_semicolon() {
fn set_ec_cookie_writes_an_identifier_in_the_alphabet_verbatim() {
let settings = create_test_settings();
let mut response = empty_response();
set_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com");

assert!(
response.headers().get(header::SET_COOKIE).is_none(),
"should not set Set-Cookie when value contains a semicolon"
);
for (case, value) in [
("an HMAC identifier", TEST_EC_ID),
(
"another HMAC identifier",
"abc123def0123456789abcdef0123456789abcdef0123456789abcdef01234567.xk92ab",
),
("a module's opaque value", "8473625190"),
// Unpadded base64url (RFC 4648 section 5) fits the alphabet, so a
// module that carries binary data encodes it that way.
("a base64url value", "abcDEF123-_x"),
] {
let mut response = empty_response();

set_ec_cookie(&settings, &mut response, value);

let cookie = response
.headers()
.get(header::SET_COOKIE)
.unwrap_or_else(|| panic!("{case}: should set the cookie"))
.to_str()
.expect("should be valid UTF-8");
assert_eq!(
cookie,
format!(
"{}={value}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly",
COOKIE_TS_EC, settings.publisher.domain, COOKIE_MAX_AGE,
),
"{case}"
);
assert_eq!(cookie, create_ec_cookie(&settings, value), "{case}");
}
}

/// A value outside the alphabet sets no cookie at all, never a rewritten
/// one, so the cookie and the identity graph key cannot diverge and no
/// value can inject an attribute or a header.
#[test]
fn set_ec_cookie_rejects_crlf() {
fn set_ec_cookie_refuses_a_value_outside_the_alphabet() {
let settings = create_test_settings();
let mut response = empty_response();
set_ec_cookie(&settings, &mut response, "evil\r\nX-Injected: header");

assert!(
response.headers().get(header::SET_COOKIE).is_none(),
"should not set Set-Cookie when value contains CRLF"
);
}
for (case, value) in [
("a semicolon", "evil; Domain=.attacker.com"),
(
"a carriage return and line feed",
"evil\r\nX-Injected: header",
),
("a line feed", "evil;injected\nfoo=bar"),
("a space", "bad value"),
] {
let mut response = empty_response();

#[test]
fn set_ec_cookie_rejects_space() {
let settings = create_test_settings();
let mut response = empty_response();
set_ec_cookie(&settings, &mut response, "bad value");
set_ec_cookie(&settings, &mut response, value);

assert!(
response.headers().get(header::SET_COOKIE).is_none(),
"should not set Set-Cookie when value contains whitespace"
);
assert!(
response.headers().get(header::SET_COOKIE).is_none(),
"{case}: should set no cookie"
);
}
}

#[test]
Expand Down
Loading
Loading