Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ both runtime behavior and build/tooling changes.
| `crates/trusted-server-core/src/tsjs.rs` | Script tag generation with module IDs |
| `crates/trusted-server-core/src/html_processor.rs` | Injects `<script>` at `<head>` start |
| `crates/trusted-server-core/src/publisher.rs` | `/static/tsjs=` handler, concatenates modules |
| `crates/trusted-server-core/src/edge_cookie.rs` | Edge Cookie (EC) ID generation |
| `crates/trusted-server-core/src/ec/` | EC identity subsystem (generation, consent, cookies) |
| `crates/trusted-server-core/src/cookies.rs` | Cookie handling |
| `crates/trusted-server-core/src/consent/mod.rs` | GDPR and broader consent management |
| `crates/trusted-server-core/src/http_util.rs` | HTTP abstractions and request utilities |
Expand Down
9 changes: 5 additions & 4 deletions crates/trusted-server-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ Behavior is covered by an extensive test suite in `crates/trusted-server-core/sr

## Edge Cookie (EC) Identifier Propagation

- `edge_cookie.rs` generates an edge cookie identifier per user request and exposes helpers:
- `generate_ec_id` — creates a fresh HMAC-based ID using the client IP address and appends a short random suffix (format: `64hex.6alnum`).
- `get_ec_id` — extracts an existing ID from the `x-ts-ec` header or `ts-ec` cookie.
- `get_or_generate_ec_id` — reuses the existing ID when present, otherwise creates one.
- The `ec/` module owns the EC identity subsystem:
- `ec/generation.rs` — creates HMAC-based IDs using the client IP and publisher passphrase (format: `64hex.6alnum`).
- `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`), `get_ec_id` helper.
- `ec/consent.rs` — EC-specific consent gating wrapper.
- `ec/cookies.rs` — `Set-Cookie` header creation and expiration helpers.
- `publisher.rs::handle_publisher_request` stamps proxied origin responses with `x-ts-ec`, and (when absent) issues the `ts-ec` cookie so the browser keeps the identifier on subsequent requests.
- `proxy.rs::handle_first_party_proxy` replays the identifier to third-party creative origins by appending `ts-ec=<value>` to the reconstructed target URL, follows redirects (301/302/303/307/308) up to four hops, and keeps downstream fetches linked to the same user scope.
- `proxy.rs::handle_first_party_click` adds `ts-ec=<value>` to outbound click redirect URLs so analytics endpoints can associate clicks with impressions without third-party cookies.
46 changes: 24 additions & 22 deletions crates/trusted-server-core/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ use error_stack::{Report, ResultExt};
use fastly::{Request, Response};

use crate::auction::formats::AdRequest;
use crate::consent;
use crate::cookies::handle_request_cookies;
use crate::edge_cookie::get_or_generate_ec_id;
use crate::ec::EcContext;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::settings::Settings;

use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request};
Expand All @@ -33,6 +30,18 @@ pub async fn handle_auction(
orchestrator: &AuctionOrchestrator,
mut req: Request,
) -> Result<Response, Report<TrustedServerError>> {
// Read EC state before consuming the request body.
let mut ec_context = EcContext::read_from_request(settings, &req).change_context(
TrustedServerError::Auction {
message: "Failed to read EC context".to_string(),
},
)?;

// Auction is an organic handler — generate EC if needed.
if let Err(err) = ec_context.generate_if_needed(settings) {
log::warn!("EC generation failed for auction: {err:?}");
}

// Parse request body
let body: AdRequest = serde_json::from_slice(&req.take_body_bytes()).change_context(
TrustedServerError::Auction {
Expand All @@ -45,27 +54,20 @@ pub async fn handle_auction(
body.ad_units.len()
);

// Generate EC ID early so the consent pipeline can use it for
// KV Store fallback/write operations.
let ec_id =
get_or_generate_ec_id(settings, &req).change_context(TrustedServerError::Auction {
message: "Failed to generate EC ID".to_string(),
})?;

// Extract consent from request cookies, headers, and geo.
let cookie_jar = handle_request_cookies(&req)?;
let geo = GeoInfo::from_request(&req);
let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput {
jar: cookie_jar.as_ref(),
req: &req,
config: &settings.consent,
geo: geo.as_ref(),
ec_id: Some(ec_id.as_str()),
});
// Only forward the EC ID to auction partners when consent allows it.
// A returning user may still have a ts-ec cookie but have since
// withdrawn consent — forwarding that revoked ID to bidders would
// defeat the consent gating.
let ec_id = if ec_context.ec_allowed() {
ec_context.ec_value().unwrap_or("")
} else {
""
};
let consent_context = ec_context.consent().clone();

// Convert tsjs request format to auction request
let auction_request =
convert_tsjs_to_auction_request(&body, settings, &req, consent_context, &ec_id)?;
convert_tsjs_to_auction_request(&body, settings, &req, consent_context, ec_id)?;

// Create auction context
let context = AuctionContext {
Expand Down
8 changes: 1 addition & 7 deletions crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ use uuid::Uuid;

use crate::auction::context::ContextValue;
use crate::consent::ConsentContext;
use crate::constants::{HEADER_X_TS_EC, HEADER_X_TS_EC_FRESH};
use crate::constants::HEADER_X_TS_EC;
use crate::creative;
use crate::edge_cookie::generate_ec_id;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt};
Expand Down Expand Up @@ -88,9 +87,6 @@ pub fn convert_tsjs_to_auction_request(
ec_id: &str,
) -> Result<AuctionRequest, Report<TrustedServerError>> {
let ec_id = ec_id.to_owned();
let fresh_id = generate_ec_id(settings, req).change_context(TrustedServerError::Auction {
message: "Failed to generate fresh EC ID".to_string(),
})?;

// Convert ad units to slots
let mut slots = Vec::new();
Expand Down Expand Up @@ -184,7 +180,6 @@ pub fn convert_tsjs_to_auction_request(
},
user: UserInfo {
id: ec_id,
fresh_id,
consent: Some(consent),
},
device,
Expand Down Expand Up @@ -312,6 +307,5 @@ pub fn convert_to_openrtb_response(
Ok(Response::from_status(StatusCode::OK)
.with_header(header::CONTENT_TYPE, "application/json")
.with_header(HEADER_X_TS_EC, &auction_request.user.id)
.with_header(HEADER_X_TS_EC_FRESH, &auction_request.user.fresh_id)
.with_body(body_bytes))
}
1 change: 0 additions & 1 deletion crates/trusted-server-core/src/auction/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,6 @@ mod tests {
},
user: UserInfo {
id: "user-123".to_string(),
fresh_id: "fresh-456".to_string(),
consent: None,
},
device: None,
Expand Down
2 changes: 0 additions & 2 deletions crates/trusted-server-core/src/auction/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ pub struct PublisherInfo {
pub struct UserInfo {
/// Stable EC ID (from cookie or freshly generated)
pub id: String,
/// Fresh ID for this session
pub fresh_id: String,
/// Decoded consent context for this request.
///
/// Carries both raw consent strings (for `OpenRTB` forwarding) and decoded
Expand Down
49 changes: 40 additions & 9 deletions crates/trusted-server-core/src/consent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,12 @@ pub fn gate_eids_by_consent<T>(
/// information on a device) must be explicitly consented. If no TCF data is
/// available under GDPR, consent is assumed absent and EC is blocked.
/// - **US state privacy**: opt-out model — EC is allowed unless the user has
/// explicitly opted out via the US Privacy string or Global Privacy Control.
/// - **Non-regulated / Unknown**: EC is allowed (no consent requirement).
/// explicitly opted out via the US Privacy string **or** Global Privacy
/// Control. GPC is checked independently — it always blocks EC creation
/// regardless of what the US Privacy string says.
/// - **Non-regulated**: EC is allowed (no consent requirement).
/// - **Unknown**: fail-closed — jurisdiction cannot be determined so EC is
/// blocked as a precaution.
#[must_use]
pub fn allows_ec_creation(ctx: &ConsentContext) -> bool {
match &ctx.jurisdiction {
Expand All @@ -488,15 +492,23 @@ pub fn allows_ec_creation(ctx: &ConsentContext) -> bool {
}
}
jurisdiction::Jurisdiction::UsState(_) => {
// US: opt-out model — allow unless user explicitly opted out.
// GPC is an independent opt-out signal — it always blocks EC
// creation regardless of what the US Privacy string says.
if ctx.gpc {
return false;
}
// Check US Privacy string for explicit opt-out.
if let Some(usp) = &ctx.us_privacy {
usp.opt_out_sale != PrivacyFlag::Yes
} else {
// No US Privacy stringfall back to GPC signal.
!ctx.gpc
// No opt-out signals presentallow under opt-out model.
true
}
}
jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => true,
jurisdiction::Jurisdiction::NonRegulated => true,
// No geolocation data — cannot determine jurisdiction.
// Fail-closed: block EC creation as a precaution.
jurisdiction::Jurisdiction::Unknown => false,
}
}

Expand Down Expand Up @@ -1029,14 +1041,33 @@ mod tests {
}

#[test]
fn ec_allowed_unknown_jurisdiction() {
fn ec_blocked_unknown_jurisdiction() {
let ctx = ConsentContext {
jurisdiction: Jurisdiction::Unknown,
..ConsentContext::default()
};
assert!(
allows_ec_creation(&ctx),
"unknown jurisdiction should allow EC (no geo data available)"
!allows_ec_creation(&ctx),
"unknown jurisdiction should block EC (fail-closed when geo unavailable)"
);
}

#[test]
fn ec_blocked_us_state_gpc_overrides_us_privacy() {
let ctx = ConsentContext {
jurisdiction: Jurisdiction::UsState("CA".to_owned()),
us_privacy: Some(UsPrivacy {
version: 1,
notice_given: PrivacyFlag::Yes,
opt_out_sale: PrivacyFlag::No,
lspa_covered: PrivacyFlag::NotApplicable,
}),
gpc: true,
..ConsentContext::default()
};
assert!(
!allows_ec_creation(&ctx),
"GPC=true should block EC even when US Privacy says no opt-out"
);
}

Expand Down
2 changes: 0 additions & 2 deletions crates/trusted-server-core/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ pub const COOKIE_TS_EC: &str = "ts-ec";

pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id");
pub const HEADER_X_TS_EC: HeaderName = HeaderName::from_static("x-ts-ec");
pub const HEADER_X_TS_EC_FRESH: HeaderName = HeaderName::from_static("x-ts-ec-fresh");
pub const HEADER_X_CONSENT_ADVERTISING: HeaderName =
HeaderName::from_static("x-consent-advertising");
pub const HEADER_X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
Expand Down Expand Up @@ -45,7 +44,6 @@ pub const HEADER_REFERER: HeaderName = HeaderName::from_static("referer");
/// in `const` context.
pub const INTERNAL_HEADERS: &[&str] = &[
"x-ts-ec",
"x-ts-ec-fresh",
"x-pub-user-id",
"x-subject-id",
"x-consent-advertising",
Expand Down
22 changes: 22 additions & 0 deletions crates/trusted-server-core/src/ec/consent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! EC-specific consent gating.
//!
//! This module provides the public consent-check API for the EC subsystem.
//! The underlying logic lives in [`crate::consent::allows_ec_creation`]; this
//! wrapper exists so that EC callers can import from `ec::consent` and the
//! eventual migration path (renaming, adding EC-specific conditions) is
//! contained here.

use crate::consent::ConsentContext;

/// Determines whether Edge Cookie creation is permitted based on the
/// user's consent and detected jurisdiction.
///
/// This is the canonical entry point for EC consent checks. It delegates
/// to [`crate::consent::allows_ec_creation`] today but may diverge as
/// EC-specific consent rules evolve.
///
/// See [`crate::consent::allows_ec_creation`] for the full decision matrix.
#[must_use]
pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool {
crate::consent::allows_ec_creation(consent_context)
}
125 changes: 125 additions & 0 deletions crates/trusted-server-core/src/ec/cookies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! EC cookie creation and expiration helpers.
//!
//! These functions handle the `Set-Cookie` header for the `ts-ec` cookie.
//! Cookie attributes follow current best practices:
//!
//! - `Domain` is computed as `.{publisher.domain}` for subdomain coverage
//! - `Path=/` makes the cookie available on all paths
//! - `Secure` restricts to HTTPS
//! - `SameSite=Lax` provides CSRF protection while allowing top-level navigations
//! - `Max-Age` of 1 year (or 0 to expire)
//! - No `HttpOnly` — the cookie needs to be readable by client-side scripts

use fastly::http::header;

use crate::constants::COOKIE_TS_EC;
use crate::settings::Settings;

/// Maximum age for the EC cookie (1 year in seconds).
const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60;

/// Formats a `Set-Cookie` header value for the EC cookie.
///
/// Centralises the cookie attribute string so that changes to security
/// attributes (e.g. adding `Partitioned`) only need updating in one place.
fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String {
format!(
"{}={}; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}",
COOKIE_TS_EC, value, domain, max_age,
)
}

/// Creates an EC cookie `Set-Cookie` header value.
///
/// Per spec §5.2, the EC cookie domain is computed from
/// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC
/// cookie is always scoped to the publisher's apex domain.
#[must_use]
pub fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String {
format_set_cookie(
&settings.publisher.ec_cookie_domain(),

Check failure on line 40 in crates/trusted-server-core/src/ec/cookies.rs

View workflow job for this annotation

GitHub Actions / prepare integration artifacts

no method named `ec_cookie_domain` found for struct `settings::Publisher` in the current scope

Check failure on line 40 in crates/trusted-server-core/src/ec/cookies.rs

View workflow job for this annotation

GitHub Actions / cargo test

no method named `ec_cookie_domain` found for struct `settings::Publisher` in the current scope

Check failure on line 40 in crates/trusted-server-core/src/ec/cookies.rs

View workflow job for this annotation

GitHub Actions / cargo fmt

no method named `ec_cookie_domain` found for struct `settings::Publisher` in the current scope
ec_id,
COOKIE_MAX_AGE,
)
}

/// Sets the EC ID cookie on the given response.
pub fn set_ec_cookie(settings: &Settings, response: &mut fastly::Response, ec_id: &str) {
response.append_header(header::SET_COOKIE, create_ec_cookie(settings, ec_id));
}

/// Expires the EC cookie by setting `Max-Age=0`.
///
/// Used when a user revokes consent — the browser will delete the cookie
/// on receipt of this header.
pub fn expire_ec_cookie(settings: &Settings, response: &mut fastly::Response) {
response.append_header(
header::SET_COOKIE,
format_set_cookie(&settings.publisher.ec_cookie_domain(), "", 0),

Check failure on line 58 in crates/trusted-server-core/src/ec/cookies.rs

View workflow job for this annotation

GitHub Actions / prepare integration artifacts

no method named `ec_cookie_domain` found for struct `settings::Publisher` in the current scope

Check failure on line 58 in crates/trusted-server-core/src/ec/cookies.rs

View workflow job for this annotation

GitHub Actions / cargo test

no method named `ec_cookie_domain` found for struct `settings::Publisher` in the current scope

Check failure on line 58 in crates/trusted-server-core/src/ec/cookies.rs

View workflow job for this annotation

GitHub Actions / cargo fmt

no method named `ec_cookie_domain` found for struct `settings::Publisher` in the current scope
);
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::tests::create_test_settings;
use fastly::http::header;

#[test]
fn create_ec_cookie_uses_computed_domain() {
let settings = create_test_settings();
let result = create_ec_cookie(&settings, "12345");

assert_eq!(
result,
format!(
"{}=12345; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}",
COOKIE_TS_EC, settings.publisher.domain, COOKIE_MAX_AGE,
),
"should use computed cookie domain (.{{domain}})"
);
}

#[test]
fn set_ec_cookie_appends_header() {
let settings = create_test_settings();
let mut response = fastly::Response::new();
set_ec_cookie(&settings, &mut response, "test-id-123");

let cookie_header = response
.get_header(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-id-123"),
"should match create_ec_cookie output"
);
}

#[test]
fn expire_ec_cookie_sets_max_age_zero() {
let settings = create_test_settings();
let mut response = fastly::Response::new();
expire_ec_cookie(&settings, &mut response);

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

assert!(
cookie_str.contains("Max-Age=0"),
"should set Max-Age=0 to expire cookie"
);
assert!(
cookie_str.starts_with(&format!("{}=;", COOKIE_TS_EC)),
"should clear cookie value"
);
assert!(
cookie_str.contains(&format!("Domain=.{}", settings.publisher.domain)),
"should use computed cookie domain"
);
}
}
Loading
Loading