Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
80 changes: 57 additions & 23 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::error::TrustedServerError;
use trusted_server_core::integrations::RequestFilterEffects;
use trusted_server_core::platform::PlatformGeo as _;
use trusted_server_core::platform::RuntimeServices;
use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body};
use trusted_server_core::response_privacy::TerminalPrivateResponse;
use trusted_server_core::settings::Settings;
Expand Down Expand Up @@ -319,11 +318,44 @@ fn run_edgezero_pull_sync_after_send(
partner_registry: &PartnerRegistry,
ec_state: &EcFinalizeState,
) {
if ec_state.is_real_browser
&& let Some(context) = build_pull_sync_context(&ec_state.ec_context)
{
run_pull_sync_after_send(settings, partner_registry, &context, &ec_state.services);
if !ec_state.is_real_browser {
return;
}

let prepared_context = build_pull_sync_context(&ec_state.ec_context, partner_registry);
let Some((context, kv)) =
prepare_pull_sync_after_send(prepared_context, || require_identity_graph(settings))
else {
return;
};

let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME);
dispatch_pull_sync(
settings,
&kv,
partner_registry,
&limiter,
&context,
&ec_state.services,
);
}

fn prepare_pull_sync_after_send<F>(
context: Option<PullSyncContext>,
graph_factory: F,
) -> Option<(PullSyncContext, KvIdentityGraph)>
where
F: FnOnce() -> Result<KvIdentityGraph, Report<TrustedServerError>>,
{
let context = context?;
let kv = match graph_factory() {
Ok(kv) => kv,
Err(err) => {
log::debug!("Pull sync: identity graph unavailable, skipping: {err:?}");
return None;
}
};
Some((context, kv))
}

/// Sends a finalized `EdgeZero` response to the client.
Expand Down Expand Up @@ -443,24 +475,6 @@ pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option<KvIdentityGrap
.map(|store_name| KvIdentityGraph::new(FastlyEcKvStore::new(store_name)))
}

fn run_pull_sync_after_send(
settings: &Settings,
partner_registry: &PartnerRegistry,
context: &PullSyncContext,
services: &RuntimeServices,
) {
let kv = match require_identity_graph(settings) {
Ok(kv) => kv,
Err(err) => {
log::debug!("Pull sync: identity graph unavailable, skipping: {err:?}");
return;
}
};

let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME);
dispatch_pull_sync(settings, &kv, partner_registry, &limiter, context, services);
}

/// Constructs a `KvIdentityGraph` from settings, or returns an error if the
/// `ec_store` config is not set.
pub(crate) fn require_identity_graph(
Expand Down Expand Up @@ -536,6 +550,26 @@ mod tests {
.expect("should parse test settings")
}

#[test]
fn pull_sync_noop_states_skip_post_send_graph_factory() {
for reason in ["no partners", "complete snapshot", "unread marker state"] {
let calls = std::cell::Cell::new(0);
let result = prepare_pull_sync_after_send(None, || {
calls.set(calls.get() + 1);
Err(Report::new(TrustedServerError::KvStore {
store_name: "unexpected".to_owned(),
message: "graph factory should not run".to_owned(),
}))
});
assert!(result.is_none(), "{reason} preparation should return none");
assert_eq!(
calls.get(),
0,
"{reason} should not invoke the graph factory"
);
}
}
Comment on lines +553 to +571

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — The three-state loop asserts the same thing three times.

for reason in ["no partners", "complete snapshot", "unread marker state"] runs three byte-identical prepare_pull_sync_after_send(None, …) calls. None of the three named states is exercised here — all three are decided inside build_pull_sync_context, which this test never calls — so the labels read as coverage that doesn't exist. What the test actually proves (a None plan never reaches the graph factory) is worth keeping; the loop is not.

Suggested change
#[test]
fn pull_sync_noop_states_skip_post_send_graph_factory() {
for reason in ["no partners", "complete snapshot", "unread marker state"] {
let calls = std::cell::Cell::new(0);
let result = prepare_pull_sync_after_send(None, || {
calls.set(calls.get() + 1);
Err(Report::new(TrustedServerError::KvStore {
store_name: "unexpected".to_owned(),
message: "graph factory should not run".to_owned(),
}))
});
assert!(result.is_none(), "{reason} preparation should return none");
assert_eq!(
calls.get(),
0,
"{reason} should not invoke the graph factory"
);
}
}
#[test]
fn pull_sync_noop_states_skip_post_send_graph_factory() {
let calls = std::cell::Cell::new(0);
let result = prepare_pull_sync_after_send(None, || {
calls.set(calls.get() + 1);
Err(Report::new(TrustedServerError::KvStore {
store_name: "unexpected".to_owned(),
message: "graph factory should not run".to_owned(),
}))
});
assert!(
result.is_none(),
"a skipped pull-sync plan should return none"
);
assert_eq!(calls.get(), 0, "should not invoke the graph factory");
}

The three states themselves are already covered in core by build_pull_sync_context_skips_empty_registry_and_complete_snapshot.

Verified in an isolated worktree at this head: cargo fmt --all -- --check and cargo clippy-fastly (which compiles --all-targets, including this test) both pass with this applied.


#[test]
fn health_response_short_circuits_get_health() {
let req = FastlyRequest::get("https://example.com/health");
Expand Down
2 changes: 2 additions & 0 deletions crates/trusted-server-core/src/constants.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use http::header::HeaderName;

pub const COOKIE_TS_EC: &str = "ts-ec";
/// 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
/// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers.
pub const COOKIE_TS_EIDS: &str = "ts-eids";
Expand Down
131 changes: 129 additions & 2 deletions crates/trusted-server-core/src/ec/finalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use super::generation::{generate_ec_id, is_valid_ec_id};
use super::kv::{CreateIfAbsentOutcome, KvIdentityGraph, apply_partner_id_updates};
use super::kv_types::KvEntry;
use super::prebid_eids::collect_eid_cookie_updates;
use super::pull_sync_marker::{expire_marker, reconcile_marker};
use super::registry::PartnerRegistry;
use super::{EcKvSnapshot, current_timestamp, log_id};

Expand Down Expand Up @@ -48,6 +49,7 @@ pub fn ec_finalize_response(
sharedid_cookie: Option<&str>,
response: &mut Response<EdgeBody>,
) {
ec_context.validate_pull_sync_marker(settings, registry);
let consent_allows_ec = ec_consent_granted(ec_context.consent());
let consent_withdrawn = ec_consent_withdrawn(ec_context.consent());

Expand All @@ -58,8 +60,12 @@ pub fn ec_finalize_response(
// consent input.
clear_ec_headers_on_response(response, Some(registry));

// Only expire the browser cookie and tombstone the identity-graph row
// when the request carries an explicit withdrawal signal.
if consent_withdrawn {
expire_marker(ec_context.pull_sync_marker_mut(), response);
}
Comment on lines +63 to +65

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Withdrawal always emits a marker-expiry Set-Cookie, downgrading cache privacy for all withdrawn traffic.

expire_marker has no presence guard — it appends the cookie whatever the state, including Absent. So every explicitly-withdrawn request (GPC=true, US-state opt-out) now returns Set-Cookie: ts-ec-pull-complete=; …Max-Age=0, even for the common case of a withdrawn visitor who has no EC cookie and never had a marker. On the base branch this path emitted no Set-Cookie at all, because expire_ec_cookie is gated on cookie_was_present().

Downstream that matters: apply_terminal_response_effects (crates/trusted-server-adapter-fastly/src/main.rs:418) runs enforce_set_cookie_cache_privacy after finalize, and for any cookie-bearing response that strips every edge-cache header and forces Cache-Control: private, max-age=0. template_cache_ttl (crates/trusted-server-core/src/publisher.rs:6168) also bypasses the shared template cache on Set-Cookie. Withdrawn requests never run the ad stack, so they never get with_cache_bypass — those responses were shareable before this change and are not after it, for an entire traffic class, as a side effect of a KV-read optimization.

Gating on was_present() keeps the plan's guarantee intact: it reads only the request cookie, never KV, so withdrawal still clears the marker with no KV dependency — exactly how ts-ec expiry is already gated on cookie_was_present(). explicit_withdrawal_expires_marker_without_ec_cookie still passes, since it seeds PullSyncMarkerState::Invalid (present).

Suggested change
if consent_withdrawn {
expire_marker(ec_context.pull_sync_marker_mut(), response);
}
if consent_withdrawn && ec_context.pull_sync_marker().was_present() {
expire_marker(ec_context.pull_sync_marker_mut(), response);
}

Verified in an isolated worktree at this head: cargo fmt --all -- --check, cargo clippy-fastly, cargo check-axum, cargo check-cloudflare, and 332 native trusted-server-core ec:: tests all pass with this applied.


// Only expire the EC cookie and tombstone the identity-graph row when
// explicit withdrawal accompanies an EC cookie.
if consent_withdrawn && ec_context.cookie_was_present() {
expire_ec_cookie(settings, response);

Expand Down Expand Up @@ -117,6 +123,8 @@ pub fn ec_finalize_response(
}
}

reconcile_pull_sync_marker(settings, registry, ec_context, response);

// Ordinary returning-user page views no longer refresh the browser
// cookie, emit the EC header, or update KV TTL.
return;
Expand All @@ -128,6 +136,7 @@ pub fn ec_finalize_response(
if ec_context.ec_generated() {
let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value().map(str::to_owned)) else {
log::info!("Skipping generated EC response write because KV graph is unavailable");
reconcile_pull_sync_marker(settings, registry, ec_context, response);
return;
};

Expand All @@ -144,6 +153,26 @@ pub fn ec_finalize_response(
log::warn!("Skipping generated EC cookie because backing row is not authoritative");
}
}

reconcile_pull_sync_marker(settings, registry, ec_context, response);
}

fn reconcile_pull_sync_marker(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — Marker issuance re-adds a Set-Cookie to responses PR #885 deliberately made cookie-free.

Same mechanism as the wrench finding above, but inherent to the design rather than a bug. The returning-user path comments that "ordinary returning-user page views no longer refresh the browser cookie" — yet a marker issue or expire makes exactly those responses cookie-bearing, so enforce_set_cookie_cache_privacy downgrades them to private, max-age=0 with edge-cache headers stripped, and Esi-mode responses stop being eligible for template-cache storage.

Bounded to roughly one response per hour per user per host, against one saved KV read per navigation, so it is plausibly a clear win — but the trade is not in the plan's risk table or in docs/guide/edge-cookies.md, and the two effects land in different budgets (KV operations vs. edge cache hit rate). Worth stating explicitly so it isn't rediscovered from a cache-hit-rate graph later.

settings: &Settings,
registry: &PartnerRegistry,
ec_context: &mut EcContext,
response: &mut Response<EdgeBody>,
) {
let ec_id = ec_context.ec_value().map(str::to_owned);
let snapshot = ec_context.kv_snapshot().clone();
reconcile_marker(
settings,
registry,
ec_id.as_deref(),
&snapshot,
ec_context.pull_sync_marker_mut(),
response,
);
}

fn recover_orphaned_ec(
Expand Down Expand Up @@ -1259,6 +1288,104 @@ mod tests {
);
}

#[test]
fn finalize_sets_marker_for_complete_pull_partner_snapshot() {
let settings = create_test_settings();
let ec_id = sample_ec_id("compl1");
let mut partner = make_partner("ssp.example.com");
partner.pull_sync_enabled = true;
partner.pull_sync_url = Some("https://sync.example.com/pull".to_owned());
partner.pull_sync_allowed_domains = vec!["sync.example.com".to_owned()];
partner.ts_pull_token = Some(Redacted::new("pull-token".to_owned()));
let registry = PartnerRegistry::from_config(&[partner]).expect("should build registry");
let mut ec_context = make_context(
Some(&ec_id),
Some(&ec_id),
true,
false,
Jurisdiction::NonRegulated,
);
let mut entry = live_entry();
entry.ids.insert(
"ssp.example.com".to_owned(),
crate::ec::kv_types::KvPartnerId {
uid: "partner-uid".to_owned(),
},
);
ec_context.set_kv_snapshot(EcKvSnapshot::Present {
ec_id,
entry: Box::new(entry),
generation: Some(1),
});
let mut response = empty_response();

ec_finalize_response(
&settings,
&mut ec_context,
None,
&registry,
None,
None,
&mut response,
);

let cookies = response
.headers()
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>();
assert!(
cookies
.iter()
.any(|cookie| cookie.starts_with("ts-ec-pull-complete=v1.")),
"complete snapshot should issue the marker"
);
}

#[test]
fn explicit_withdrawal_expires_marker_without_ec_cookie() {
let settings = create_test_settings();
let consent = ConsentContext {
jurisdiction: Jurisdiction::UsState("CA".to_owned()),
gpc: true,
source: ConsentSource::Cookie,
..Default::default()
};
let mut ec_context = make_context_with_consent(None, None, false, false, consent);
ec_context.set_pull_sync_marker_for_test(
crate::ec::pull_sync_marker::PullSyncMarkerState::Invalid,
);
let mut response = empty_response();

ec_finalize_response(
&settings,
&mut ec_context,
None,
&PartnerRegistry::empty(),
None,
None,
&mut response,
);

let cookies = response
.headers()
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>();
assert!(
cookies.iter().any(|cookie| {
cookie.starts_with("ts-ec-pull-complete=;") && cookie.contains("Max-Age=0")
}),
"withdrawal should expire the marker independently of EC cookie state"
);
assert!(
cookies.iter().all(|cookie| !cookie.starts_with("ts-ec=;")),
"missing EC cookie should not add an EC-cookie expiry"
);
}

fn live_entry() -> KvEntry {
let mut entry = KvEntry::tombstone(1000);
entry.consent.ok = true;
Expand Down
Loading
Loading