diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml
index 87c59d2f..9c95f8bb 100644
--- a/.github/actions/setup-integration-test-env/action.yml
+++ b/.github/actions/setup-integration-test-env/action.yml
@@ -80,7 +80,7 @@ runs:
env:
TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }}
TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret
- TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY: integration-test-secret-key
+ TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32
TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false"
run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 186569da..2da273aa 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -51,6 +51,14 @@ jobs:
- name: Run tests
run: cargo test --workspace
+ - name: Verify Fastly WASM release build
+ env:
+ TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080
+ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret
+ TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32
+ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false"
+ run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
+
test-typescript:
name: vitest
runs-on: ubuntu-latest
diff --git a/CLAUDE.md b/CLAUDE.md
index ec76ee46..b5e2b6f0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 ``
- - The bundle guards anchor clicks by restoring the originally rewritten first‑party link at click time.
- - Served through the unified endpoint described below.
+ - Injected at the top of `
`: ``
+ - The bundle guards anchor clicks by restoring the originally rewritten first‑party link at click time.
+ - Served through the unified endpoint described below.
Helpers:
@@ -41,18 +41,19 @@ Helpers:
JS bundles (served by publisher module):
- Dynamic endpoint: `/static/tsjs=tsjs-unified.min.js?v=`
- - At build time, each integration is compiled as a separate IIFE (`tsjs-core.js`, `tsjs-prebid.js`, `tsjs-creative.js`, etc.)
- - At runtime, the server concatenates `tsjs-core.js` + enabled integration modules based on `IntegrationRegistry` config
- - The URL filename is fixed for backward compatibility; the `?v=` hash changes when modules change
+ - At build time, each integration is compiled as a separate IIFE (`tsjs-core.js`, `tsjs-prebid.js`, `tsjs-creative.js`, etc.)
+ - At runtime, the server concatenates `tsjs-core.js` + enabled integration modules based on `IntegrationRegistry` config
+ - The URL filename is fixed for backward compatibility; the `?v=` hash changes when modules change
Behavior is covered by an extensive test suite in `crates/trusted-server-core/src/creative.rs`.
## 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.
-- `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.
+- 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` issues the `ts-ec` cookie when absent 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=` 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=` to outbound click redirect URLs so analytics endpoints can associate clicks with impressions without third-party cookies.
diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md
index 7685e828..e92c4dbb 100644
--- a/crates/trusted-server-core/src/auction/README.md
+++ b/crates/trusted-server-core/src/auction/README.md
@@ -257,18 +257,18 @@ The trusted-server handles several types of routes defined in `crates/trusted-se
| Route | Method | Handler | Purpose | Line |
|---------------------------|--------|--------------------------------|--------------------------------------------------|------|
-| `/auction` | POST | `handle_auction()` | Main auction endpoint (Prebid.js/tsjs format) | 162 |
-| `/first-party/proxy` | GET | `handle_first_party_proxy()` | Proxy creatives through first-party domain | 167 |
-| `/first-party/click` | GET | `handle_first_party_click()` | Track clicks on ads | 170 |
-| `/first-party/sign` | GET/POST | `handle_first_party_proxy_sign()` | Generate signed URLs for creatives | 173 |
-| `/first-party/proxy-rebuild` | POST | `handle_first_party_proxy_rebuild()` | Rebuild creative HTML with new settings | 176 |
-| `/static/tsjs=*` | GET | `handle_tsjs_dynamic()` | Serve tsjs library (Prebid.js alternative) | 145 |
-| `/.well-known/trusted-server.json` | GET | `handle_trusted_server_discovery()` | Public key distribution for request signing | 149 |
-| `/verify-signature` | POST | `handle_verify_signature()` | Verify signed requests | 154 |
-| `/admin/keys/rotate` | POST | `handle_rotate_key()` | Rotate signing keys (admin only) | 158 |
-| `/admin/keys/deactivate` | POST | `handle_deactivate_key()` | Deactivate signing keys (admin only) | 159 |
-| `/integrations/*` | * | Integration Registry | Provider-specific endpoints (Prebid, etc.) | 179 |
-| `*` (fallback) | * | `handle_publisher_request()` | Proxy to publisher origin | 195 |
+| `/auction` | POST | `handle_auction()` | Main auction endpoint (Prebid.js/tsjs format) | 84 |
+| `/first-party/proxy` | GET | `handle_first_party_proxy()` | Proxy creatives through first-party domain | 84 |
+| `/first-party/click` | GET | `handle_first_party_click()` | Track clicks on ads | 85 |
+| `/first-party/sign` | GET/POST | `handle_first_party_proxy_sign()` | Generate signed URLs for creatives | 86 |
+| `/first-party/proxy-rebuild` | POST | `handle_first_party_proxy_rebuild()` | Rebuild creative HTML with new settings | 89 |
+| `/static/tsjs=*` | GET | `handle_tsjs_dynamic()` | Serve tsjs library (Prebid.js alternative) | 66 |
+| `/.well-known/ts.jwks.json` | GET | `handle_jwks_endpoint()` | Public key distribution for request signing | 71 |
+| `/verify-signature` | POST | `handle_verify_signature()` | Verify signed requests | 74 |
+| `/_ts/admin/keys/rotate` | POST | `handle_rotate_key()` | Rotate signing keys (admin only) | 77 |
+| `/_ts/admin/keys/deactivate` | POST | `handle_deactivate_key()` | Deactivate signing keys (admin only) | 78 |
+| `/integrations/*` | * | Integration Registry | Provider-specific endpoints (Prebid, etc.) | 92 |
+| `*` (fallback) | * | `handle_publisher_request()` | Proxy to publisher origin | 108 |
### How Routing Works
@@ -277,50 +277,22 @@ The Fastly Compute entrypoint uses pattern matching on `(Method, path)` tuples:
```rust
let result = match (method, path.as_str()) {
- (Method::GET, path) if path.starts_with("/static/tsjs=") => {
- handle_tsjs_dynamic(&req, integration_registry)
- }
- (Method::GET, "/.well-known/trusted-server.json") => {
- handle_trusted_server_discovery(settings, runtime_services, req)
- }
- (Method::POST, "/verify-signature") => handle_verify_signature(settings, req),
- (Method::POST, "/admin/keys/rotate") => handle_rotate_key(settings, req),
- (Method::POST, "/admin/keys/deactivate") => handle_deactivate_key(settings, req),
+ // Auction endpoint
(Method::POST, "/auction") => {
- match runtime_services_for_consent_route(settings, runtime_services) {
- Ok(auction_services) => {
- handle_auction(settings, orchestrator, &auction_services, req).await
- }
- Err(e) => Err(e),
- }
- }
- (Method::GET, "/first-party/proxy") => {
- handle_first_party_proxy(settings, runtime_services, req).await
- }
- (Method::GET, "/first-party/click") => {
- handle_first_party_click(settings, runtime_services, req).await
- }
- (Method::GET, "/first-party/sign") | (Method::POST, "/first-party/sign") => {
- handle_first_party_proxy_sign(settings, runtime_services, req).await
- }
- (Method::POST, "/first-party/proxy-rebuild") => {
- handle_first_party_proxy_rebuild(settings, runtime_services, req).await
- }
- (m, path) if integration_registry.has_route(&m, path) => integration_registry
- .handle_proxy(&m, path, settings, runtime_services, req)
- .await
- .unwrap_or_else(|| {
- Err(Report::new(TrustedServerError::BadRequest {
- message: format!("Unknown integration route: {path}"),
- }))
- }),
- _ => match runtime_services_for_consent_route(settings, runtime_services) {
- Ok(publisher_services) => {
- handle_publisher_request(settings, integration_registry, &publisher_services, req)
- }
- Err(e) => Err(e),
+ handle_auction(&settings, &orchestrator, &runtime_services, req).await
},
-};
+
+ // First-party endpoints
+ (Method::GET, "/first-party/proxy") => handle_first_party_proxy(&settings, req).await,
+
+ // Integration registry (dynamic routes)
+ (m, path) if integration_registry.has_route(&m, path) => {
+ integration_registry.handle_proxy(&m, path, &settings, req).await
+ },
+
+ // Fallback to publisher origin
+ _ => handle_publisher_request(&settings, &integration_registry, &runtime_services, req),
+}
```
#### 2. Integration Registry (Dynamic Routes)
@@ -346,7 +318,7 @@ The integration registry checks if a route matches any registered integration ro
#### 3. Route Priority
Routes are matched in this order:
1. **Exact top-level routes** (`/auction`, `/first-party/proxy`, etc.)
-2. **Admin routes** (`/admin/*`)
+2. **Admin routes** (`/_ts/admin/*`)
3. **Integration routes** (`/integrations/*`)
4. **Fallback to publisher origin** (all other paths)
diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs
index 0430f08b..64832eef 100644
--- a/crates/trusted-server-core/src/auction/endpoints.rs
+++ b/crates/trusted-server-core/src/auction/endpoints.rs
@@ -2,13 +2,20 @@
use error_stack::{Report, ResultExt};
use fastly::{Request, Response};
+use serde_json::Value as JsonValue;
use crate::auction::formats::AdRequest;
-use crate::compat;
-use crate::consent;
-use crate::cookies::handle_request_cookies;
-use crate::edge_cookie::get_or_generate_ec_id_from_http_request;
+use crate::consent::gate_eids_by_consent;
+use crate::constants::COOKIE_TS_EIDS;
+use crate::ec::eids::{resolve_partner_ids, to_eids};
+use crate::ec::kv::KvIdentityGraph;
+use crate::ec::kv_types::MAX_UID_LENGTH;
+use crate::ec::log_id;
+use crate::ec::prebid_eids::parse_prebid_eids_cookie;
+use crate::ec::registry::PartnerRegistry;
+use crate::ec::EcContext;
use crate::error::TrustedServerError;
+use crate::openrtb::{Eid, Uid};
use crate::platform::RuntimeServices;
use crate::settings::Settings;
@@ -16,6 +23,10 @@ use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_reques
use super::types::AuctionContext;
use super::AuctionOrchestrator;
+const MAX_CLIENT_EID_SOURCES: usize = 64;
+const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32;
+const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255;
+
/// Handle auction request from /auction endpoint.
///
/// This is the main entry point for running header bidding auctions.
@@ -32,6 +43,9 @@ use super::AuctionOrchestrator;
pub async fn handle_auction(
settings: &Settings,
orchestrator: &AuctionOrchestrator,
+ kv: Option<&KvIdentityGraph>,
+ registry: Option<&PartnerRegistry>,
+ ec_context: &EcContext,
services: &RuntimeServices,
mut req: Request,
) -> Result> {
@@ -47,53 +61,54 @@ pub async fn handle_auction(
body.ad_units.len()
);
- let http_req = compat::from_fastly_headers_ref(&req);
+ // Story 5 middleware contract: auction is a read-only EC route.
+ // It must not generate EC IDs; it only consumes pre-routed context.
+ // 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()
+ } else {
+ // Intentionally omit persistent identity when EC is disallowed.
+ // This keeps the no-consent / GPC path conservative rather than
+ // introducing a secondary session-scoped identifier surface here.
+ None
+ };
+ let consent_context = ec_context.consent().clone();
- // 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_from_http_request(settings, services, &http_req)
- .change_context(TrustedServerError::Auction {
- message: "Failed to generate EC ID".to_string(),
- })?;
+ // Parse client-provided EIDs from the current request body. When the
+ // current request does not include them, fall back to the persisted
+ // `ts-eids` cookie so later requests can still forward the browser's
+ // full OpenRTB-style EID structure.
+ let client_eids = resolve_client_auction_eids(
+ body.eids.as_ref(),
+ extract_cookie_value(&req, COOKIE_TS_EIDS).as_deref(),
+ );
- // Extract consent from request cookies, headers, and geo.
- let cookie_jar = handle_request_cookies(&http_req)?;
- let geo = services
- .geo()
- .lookup(services.client_info.client_ip)
- .unwrap_or_else(|e| {
- log::warn!("geo lookup failed: {e}");
- None
- });
- let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput {
- jar: cookie_jar.as_ref(),
- req: &http_req,
- config: &settings.consent,
- geo: geo.as_ref(),
- ec_id: Some(ec_id.as_str()),
- kv_store: settings
- .consent
- .consent_store
- .as_deref()
- .map(|_| services.kv_store()),
- });
+ // Resolve partner EIDs from the KV identity graph when the user has
+ // a valid EC and both KV and partner stores are available.
+ let eids = resolve_auction_eids(kv, registry, ec_context);
// Convert tsjs request format to auction request
- let auction_request = convert_tsjs_to_auction_request(
- &body,
- settings,
- services,
- &req,
- consent_context,
- &ec_id,
- geo,
- )?;
+ let mut auction_request =
+ convert_tsjs_to_auction_request(&body, settings, &req, consent_context, ec_id)?;
+
+ // Merge current-request client EIDs with KV-resolved EIDs, then apply
+ // consent gating before attaching them to the auction request.
+ // `gate_eids_by_consent` checks TCF Purpose 1 + 4.
+ let merged_eids = merge_auction_eids(client_eids, eids);
+ let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty());
+ auction_request.user.eids =
+ gate_eids_by_consent(merged_eids, auction_request.user.consent.as_ref());
+ if had_eids && auction_request.user.eids.is_none() {
+ log::warn!("Auction EIDs stripped by TCF consent gating");
+ }
// Create auction context
let context = AuctionContext {
settings,
request: &req,
- client_info: &services.client_info,
timeout_ms: settings.auction.timeout_ms,
provider_responses: None,
services,
@@ -101,7 +116,7 @@ pub async fn handle_auction(
// Run the auction
let result = orchestrator
- .run_auction(&auction_request, &context, services)
+ .run_auction(&auction_request, &context)
.await
.change_context(TrustedServerError::Auction {
message: "Auction orchestration failed".to_string(),
@@ -115,5 +130,583 @@ pub async fn handle_auction(
);
// Convert to OpenRTB response format with inline creative HTML
- convert_to_openrtb_response(&result, settings, &auction_request)
+ convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed())
+}
+
+/// Resolves partner EIDs from the KV identity graph for bidstream decoration.
+///
+/// Returns `None` when any prerequisite is missing (no KV store, no partner
+/// store, no EC, consent denied). On KV or partner-resolution errors, logs a
+/// warning and returns empty EIDs so the auction can proceed in degraded mode.
+fn resolve_auction_eids(
+ kv: Option<&KvIdentityGraph>,
+ registry: Option<&PartnerRegistry>,
+ ec_context: &EcContext,
+) -> Option> {
+ let kv = kv?;
+ let registry = registry?;
+
+ if !ec_context.ec_allowed() {
+ return None;
+ }
+
+ let ec_id = ec_context.ec_value()?;
+
+ let entry = match kv.get(ec_id) {
+ Ok(Some((entry, _generation))) => entry,
+ Ok(None) => return Some(Vec::new()),
+ Err(err) => {
+ log::warn!(
+ "Auction KV read failed for EC ID '{}': {err:?}",
+ log_id(ec_id)
+ );
+ return Some(Vec::new());
+ }
+ };
+
+ let resolved = resolve_partner_ids(registry, &entry);
+ Some(to_eids(&resolved))
+}
+
+fn extract_cookie_value(req: &Request, name: &str) -> Option {
+ let cookie_header = req.get_header_str("cookie")?;
+ for pair in cookie_header.split(';') {
+ let pair = pair.trim();
+ if let Some((key, value)) = pair.split_once('=') {
+ if key.trim() == name {
+ return Some(value.trim().to_owned());
+ }
+ }
+ }
+ None
+}
+
+fn resolve_client_auction_eids(
+ raw: Option<&JsonValue>,
+ cookie_value: Option<&str>,
+) -> Option> {
+ parse_client_auction_eids(raw).or_else(|| parse_cookie_auction_eids(cookie_value))
+}
+
+fn parse_cookie_auction_eids(cookie_value: Option<&str>) -> Option> {
+ let cookie_value = cookie_value?;
+ match parse_prebid_eids_cookie(cookie_value) {
+ Ok(eids) if eids.is_empty() => None,
+ Ok(eids) => Some(eids),
+ Err(_) => {
+ log::trace!("Auction EIDs: failed to parse ts-eids cookie; dropping");
+ None
+ }
+ }
+}
+
+fn parse_client_auction_eids(raw: Option<&JsonValue>) -> Option> {
+ let Some(JsonValue::Array(entries)) = raw else {
+ return None;
+ };
+
+ let mut eids = Vec::new();
+
+ for entry in entries {
+ if eids.len() >= MAX_CLIENT_EID_SOURCES {
+ log::debug!(
+ "Auction EIDs: reached max client EID source count ({MAX_CLIENT_EID_SOURCES})"
+ );
+ break;
+ }
+ let JsonValue::Object(entry) = entry else {
+ log::debug!("Auction EIDs: dropping malformed client EID entry");
+ continue;
+ };
+
+ let Some(source) = entry
+ .get("source")
+ .and_then(JsonValue::as_str)
+ .filter(|source| !source.trim().is_empty())
+ .filter(|source| source.len() <= MAX_CLIENT_EID_SOURCE_BYTES)
+ .map(str::to_owned)
+ else {
+ continue;
+ };
+
+ let Some(JsonValue::Array(raw_uids)) = entry.get("uids") else {
+ continue;
+ };
+
+ let uids: Vec<_> = raw_uids
+ .iter()
+ .filter_map(parse_client_auction_uid)
+ .take(MAX_CLIENT_UIDS_PER_SOURCE)
+ .collect();
+ if uids.is_empty() {
+ continue;
+ }
+
+ eids.push(Eid { source, uids });
+ }
+
+ if eids.is_empty() {
+ None
+ } else {
+ Some(eids)
+ }
+}
+
+fn parse_client_auction_uid(raw: &JsonValue) -> Option {
+ let JsonValue::Object(uid) = raw else {
+ return None;
+ };
+
+ let id = uid
+ .get("id")
+ .and_then(JsonValue::as_str)
+ .filter(|id| !id.trim().is_empty())
+ .filter(|id| id.len() <= MAX_UID_LENGTH)?
+ .to_owned();
+
+ let atype = uid
+ .get("atype")
+ .and_then(JsonValue::as_u64)
+ .and_then(|atype| u8::try_from(atype).ok());
+
+ let ext = match uid.get("ext") {
+ Some(JsonValue::Object(_)) => uid.get("ext").cloned(),
+ _ => None,
+ };
+
+ Some(Uid { id, atype, ext })
+}
+
+fn merge_auction_eids(
+ client_eids: Option>,
+ resolved_eids: Option>,
+) -> Option> {
+ let mut merged = Vec::new();
+
+ for eid in resolved_eids
+ .into_iter()
+ .flatten()
+ .chain(client_eids.into_iter().flatten())
+ {
+ if eid.source.is_empty() {
+ continue;
+ }
+
+ let source_index = match merged
+ .iter()
+ .position(|existing: &Eid| existing.source == eid.source)
+ {
+ Some(index) => index,
+ None => {
+ merged.push(Eid {
+ source: eid.source.clone(),
+ uids: Vec::new(),
+ });
+ merged.len() - 1
+ }
+ };
+
+ for uid in eid.uids {
+ if uid.id.trim().is_empty() || uid.id.len() > MAX_UID_LENGTH {
+ continue;
+ }
+
+ if let Some(existing_uid) = merged[source_index]
+ .uids
+ .iter_mut()
+ .find(|existing| existing.id == uid.id)
+ {
+ if existing_uid.atype.is_none() {
+ existing_uid.atype = uid.atype;
+ }
+ if existing_uid.ext.is_none() {
+ existing_uid.ext = uid.ext;
+ }
+ } else {
+ merged[source_index].uids.push(uid);
+ }
+ }
+ }
+
+ merged.retain(|eid| !eid.uids.is_empty());
+
+ if merged.is_empty() {
+ None
+ } else {
+ Some(merged)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::consent::jurisdiction::Jurisdiction;
+ use crate::consent::types::ConsentContext;
+ use crate::openrtb::Uid;
+ use base64::engine::general_purpose::STANDARD as BASE64;
+ use base64::Engine as _;
+ use serde_json::json;
+
+ fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext {
+ EcContext::new_for_test(
+ ec_value.map(str::to_owned),
+ ConsentContext {
+ jurisdiction,
+ ..ConsentContext::default()
+ },
+ )
+ }
+
+ #[test]
+ fn resolve_auction_eids_returns_none_without_kv() {
+ let registry = PartnerRegistry::empty();
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
+
+ let result = resolve_auction_eids(None, Some(®istry), &ec_context);
+ assert!(result.is_none(), "should return None when KV is missing");
+ }
+
+ #[test]
+ fn resolve_auction_eids_returns_none_without_registry() {
+ let kv = KvIdentityGraph::new("test_store");
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
+
+ let result = resolve_auction_eids(Some(&kv), None, &ec_context);
+ assert!(
+ result.is_none(),
+ "should return None when registry is missing"
+ );
+ }
+
+ #[test]
+ fn resolve_auction_eids_returns_none_when_consent_denied() {
+ let kv = KvIdentityGraph::new("test_store");
+ let registry = PartnerRegistry::empty();
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id));
+
+ let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context);
+ assert!(
+ result.is_none(),
+ "should return None when consent is denied"
+ );
+ }
+
+ #[test]
+ fn resolve_auction_eids_returns_none_when_no_ec() {
+ let kv = KvIdentityGraph::new("test_store");
+ let registry = PartnerRegistry::empty();
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
+
+ let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context);
+ assert!(
+ result.is_none(),
+ "should return None when no EC value is present"
+ );
+ }
+
+ #[test]
+ fn resolve_auction_eids_returns_empty_on_kv_miss() {
+ let kv = KvIdentityGraph::new("nonexistent_store");
+ let registry = PartnerRegistry::empty();
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
+
+ // KV store doesn't exist, so the get() call will error — should return
+ // empty Vec (degraded mode), not None.
+ let result = resolve_auction_eids(Some(&kv), Some(®istry), &ec_context);
+ let eids = result.expect("should return Some on KV error (degraded mode)");
+ assert!(
+ eids.is_empty(),
+ "should return empty vec on KV error (degraded mode)"
+ );
+ }
+
+ #[test]
+ fn resolve_client_auction_eids_falls_back_to_ts_eids_cookie() {
+ let cookie_payload = json!([
+ {
+ "source": "sharedid.org",
+ "uids": [
+ { "id": "shared_cookie", "atype": 3 },
+ { "id": "shared_cookie_2", "ext": { "provider": "example" } }
+ ]
+ }
+ ]);
+ let encoded = BASE64
+ .encode(serde_json::to_vec(&cookie_payload).expect("should serialize cookie payload"));
+
+ let resolved = resolve_client_auction_eids(None, Some(&encoded))
+ .expect("should fall back to structured ts-eids cookie");
+
+ assert_eq!(resolved.len(), 1, "should preserve cookie source entry");
+ assert_eq!(resolved[0].source, "sharedid.org");
+ assert_eq!(
+ resolved[0].uids.len(),
+ 2,
+ "should preserve multiple cookie UIDs"
+ );
+ assert_eq!(resolved[0].uids[0].id, "shared_cookie");
+ assert_eq!(
+ resolved[0].uids[1].ext,
+ Some(json!({ "provider": "example" })),
+ "should preserve UID ext from cookie fallback"
+ );
+ }
+
+ #[test]
+ fn resolve_client_auction_eids_prefers_request_body_over_cookie() {
+ let raw = json!([
+ {
+ "source": "id5-sync.com",
+ "uids": [{ "id": "body_uid", "atype": 1 }]
+ }
+ ]);
+ let cookie_payload = json!([
+ {
+ "source": "sharedid.org",
+ "uids": [{ "id": "cookie_uid", "atype": 3 }]
+ }
+ ]);
+ let encoded = BASE64
+ .encode(serde_json::to_vec(&cookie_payload).expect("should serialize cookie payload"));
+
+ let resolved = resolve_client_auction_eids(Some(&raw), Some(&encoded))
+ .expect("should prefer request body EIDs");
+
+ assert_eq!(resolved.len(), 1, "should use request body when present");
+ assert_eq!(resolved[0].source, "id5-sync.com");
+ assert_eq!(resolved[0].uids[0].id, "body_uid");
+ }
+
+ #[test]
+ fn parse_client_auction_eids_ignores_malformed_entries() {
+ let raw = json!([
+ {
+ "source": "id5-sync.com",
+ "uids": [{ "id": "ID5_abc", "atype": 1 }]
+ },
+ {
+ "source": "broken.example",
+ "uids": "not-an-array"
+ },
+ {
+ "source": "sharedid.org",
+ "uids": [{ "id": "shared_123" }, { "id": "" }]
+ }
+ ]);
+
+ let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse valid EIDs");
+
+ assert_eq!(parsed.len(), 2, "should keep only valid EID entries");
+ assert_eq!(parsed[0].source, "id5-sync.com");
+ assert_eq!(parsed[0].uids.len(), 1, "should keep valid UID");
+ assert_eq!(parsed[1].source, "sharedid.org");
+ assert_eq!(parsed[1].uids.len(), 1, "should drop empty UID values");
+ }
+
+ #[test]
+ fn parse_client_auction_eids_caps_sources_and_uids() {
+ let entries: Vec<_> = (0..(MAX_CLIENT_EID_SOURCES + 5))
+ .map(|source_index| {
+ let uids: Vec<_> = (0..(MAX_CLIENT_UIDS_PER_SOURCE + 5))
+ .map(|uid_index| json!({ "id": format!("uid-{source_index}-{uid_index}") }))
+ .collect();
+ json!({
+ "source": format!("source-{source_index}.example.com"),
+ "uids": uids,
+ })
+ })
+ .collect();
+ let raw = JsonValue::Array(entries);
+
+ let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse capped EIDs");
+
+ assert_eq!(
+ parsed.len(),
+ MAX_CLIENT_EID_SOURCES,
+ "should cap client EID sources"
+ );
+ assert!(
+ parsed
+ .iter()
+ .all(|eid| eid.uids.len() == MAX_CLIENT_UIDS_PER_SOURCE),
+ "should cap UIDs per source"
+ );
+ }
+
+ #[test]
+ fn parse_client_auction_eids_drops_whitespace_and_oversized_uids() {
+ let raw = json!([
+ {
+ "source": "id5-sync.com",
+ "uids": [
+ { "id": " " },
+ { "id": "x".repeat(MAX_UID_LENGTH + 1) },
+ { "id": "valid" }
+ ]
+ }
+ ]);
+
+ let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse valid UID");
+
+ assert_eq!(parsed.len(), 1, "should retain source with valid UID");
+ assert_eq!(parsed[0].uids.len(), 1, "should drop invalid UIDs");
+ assert_eq!(parsed[0].uids[0].id, "valid", "should keep valid UID");
+ }
+
+ #[test]
+ fn parse_client_auction_eids_preserves_uid_ext_and_sanitizes_invalid_atype() {
+ let raw = json!([
+ {
+ "source": "adserver.org",
+ "uids": [
+ {
+ "id": "uid-with-ext",
+ "atype": 1,
+ "ext": { "provider": "liveintent.com", "rtiPartner": "TDID" }
+ },
+ {
+ "id": "uid-bad-atype",
+ "atype": 999,
+ "ext": { "keep": true }
+ },
+ {
+ "id": "uid-float-atype",
+ "atype": 1.5
+ }
+ ]
+ }
+ ]);
+
+ let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse valid EIDs");
+
+ assert_eq!(parsed.len(), 1, "should keep valid source");
+ assert_eq!(parsed[0].uids.len(), 3, "should keep valid UIDs");
+ assert_eq!(
+ parsed[0].uids[0].atype,
+ Some(1),
+ "should preserve valid atype"
+ );
+ assert_eq!(
+ parsed[0].uids[0].ext,
+ Some(json!({ "provider": "liveintent.com", "rtiPartner": "TDID" })),
+ "should preserve uid ext"
+ );
+ assert_eq!(
+ parsed[0].uids[1].atype, None,
+ "should drop out-of-range atype without dropping uid"
+ );
+ assert_eq!(
+ parsed[0].uids[1].ext,
+ Some(json!({ "keep": true })),
+ "should preserve ext when atype is invalid"
+ );
+ assert_eq!(
+ parsed[0].uids[2].atype, None,
+ "should drop non-integer atype without dropping uid"
+ );
+ }
+
+ #[test]
+ fn merge_auction_eids_deduplicates_client_and_resolved_ids() {
+ let client_eids = Some(vec![Eid {
+ source: "id5-sync.com".to_string(),
+ uids: vec![Uid {
+ id: "ID5_abc".to_string(),
+ atype: Some(1),
+ ext: None,
+ }],
+ }]);
+ let resolved_eids = Some(vec![
+ Eid {
+ source: "id5-sync.com".to_string(),
+ uids: vec![Uid {
+ id: "ID5_abc".to_string(),
+ atype: Some(1),
+ ext: None,
+ }],
+ },
+ Eid {
+ source: "liveramp.com".to_string(),
+ uids: vec![Uid {
+ id: "LR_xyz".to_string(),
+ atype: Some(3),
+ ext: None,
+ }],
+ },
+ ]);
+
+ let merged = merge_auction_eids(client_eids, resolved_eids).expect("should merge EIDs");
+
+ assert_eq!(merged.len(), 2, "should retain distinct EID sources");
+ assert_eq!(merged[0].source, "id5-sync.com");
+ assert_eq!(merged[0].uids.len(), 1, "should deduplicate matching UIDs");
+ assert_eq!(merged[1].source, "liveramp.com");
+ assert_eq!(merged[1].uids[0].id, "LR_xyz");
+ }
+
+ #[test]
+ fn merge_auction_eids_preserves_multiple_uids_per_source() {
+ let client_eids = Some(vec![Eid {
+ source: "sharedid.org".to_string(),
+ uids: vec![Uid {
+ id: "shared_client".to_string(),
+ atype: None,
+ ext: None,
+ }],
+ }]);
+ let resolved_eids = Some(vec![Eid {
+ source: "sharedid.org".to_string(),
+ uids: vec![Uid {
+ id: "shared_server".to_string(),
+ atype: Some(3),
+ ext: None,
+ }],
+ }]);
+
+ let merged = merge_auction_eids(client_eids, resolved_eids).expect("should merge EIDs");
+
+ assert_eq!(merged.len(), 1, "should merge same-source entries");
+ assert_eq!(merged[0].uids.len(), 2, "should preserve distinct UIDs");
+ assert_eq!(merged[0].uids[0].id, "shared_server");
+ assert_eq!(merged[0].uids[1].id, "shared_client");
+ }
+
+ #[test]
+ fn merge_auction_eids_prefers_server_resolved_metadata_on_conflict() {
+ let client_eids = Some(vec![Eid {
+ source: "adserver.org".to_string(),
+ uids: vec![Uid {
+ id: "shared_uid".to_string(),
+ atype: Some(1),
+ ext: Some(json!({ "provider": "client" })),
+ }],
+ }]);
+ let resolved_eids = Some(vec![Eid {
+ source: "adserver.org".to_string(),
+ uids: vec![Uid {
+ id: "shared_uid".to_string(),
+ atype: Some(3),
+ ext: Some(json!({ "provider": "server" })),
+ }],
+ }]);
+
+ let merged = merge_auction_eids(client_eids, resolved_eids).expect("should merge EIDs");
+
+ assert_eq!(merged.len(), 1, "should merge duplicate source");
+ assert_eq!(merged[0].uids.len(), 1, "should deduplicate duplicate uid");
+ assert_eq!(
+ merged[0].uids[0].atype,
+ Some(3),
+ "should prefer resolved atype"
+ );
+ assert_eq!(
+ merged[0].uids[0].ext,
+ Some(json!({ "provider": "server" })),
+ "should prefer resolved ext"
+ );
+ }
}
diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs
index 5237921a..08d6a6cd 100644
--- a/crates/trusted-server-core/src/auction/formats.rs
+++ b/crates/trusted-server-core/src/auction/formats.rs
@@ -14,12 +14,12 @@ 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_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED};
use crate::creative;
-use crate::edge_cookie::generate_ec_id;
+use crate::ec::eids::encode_eids_header;
use crate::error::TrustedServerError;
+use crate::geo::GeoInfo;
use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt};
-use crate::platform::{GeoInfo, RuntimeServices};
use crate::settings::Settings;
use super::orchestrator::OrchestrationResult;
@@ -34,6 +34,7 @@ use super::types::{
pub struct AdRequest {
pub ad_units: Vec,
pub config: Option,
+ pub eids: Option,
}
#[derive(Debug, Deserialize)]
@@ -83,17 +84,11 @@ pub struct BannerUnit {
pub fn convert_tsjs_to_auction_request(
body: &AdRequest,
settings: &Settings,
- services: &RuntimeServices,
req: &Request,
consent: ConsentContext,
- ec_id: &str,
- geo: Option,
+ ec_id: Option<&str>,
) -> Result> {
- let ec_id = ec_id.to_owned();
- let fresh_id =
- generate_ec_id(settings, services).change_context(TrustedServerError::Auction {
- message: "Failed to generate fresh EC ID".to_string(),
- })?;
+ let ec_id = ec_id.map(str::to_owned);
// Convert ad units to slots
let mut slots = Vec::new();
@@ -140,8 +135,9 @@ pub fn convert_tsjs_to_auction_request(
user_agent: req
.get_header_str("user-agent")
.map(std::string::ToString::to_string),
- ip: services.client_info.client_ip.map(|ip| ip.to_string()),
- geo,
+ ip: req.get_client_ip_addr().map(|ip| ip.to_string()),
+ #[allow(deprecated)]
+ geo: GeoInfo::from_request(req),
});
// Forward allowed config entries from the JS request into the context map.
@@ -187,8 +183,8 @@ pub fn convert_tsjs_to_auction_request(
},
user: UserInfo {
id: ec_id,
- fresh_id,
consent: Some(consent),
+ eids: None,
},
device,
site: Some(SiteInfo {
@@ -212,6 +208,7 @@ pub fn convert_to_openrtb_response(
result: &OrchestrationResult,
settings: &Settings,
auction_request: &AuctionRequest,
+ ec_allowed: bool,
) -> Result> {
// Build OpenRTB-style seatbid array
let mut seatbids = Vec::with_capacity(result.winning_bids.len());
@@ -312,9 +309,177 @@ pub fn convert_to_openrtb_response(
message: "Failed to serialize auction response".to_string(),
})?;
- Ok(Response::from_status(StatusCode::OK)
+ let mut response = 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))
+ .with_body(body_bytes);
+
+ // Signal consent status independently of whether EIDs were resolved.
+ // A user may have granted consent but have no partner syncs yet;
+ // downstream clients rely on this header to know consent was verified.
+ if ec_allowed {
+ response.set_header(HEADER_X_TS_EC_CONSENT, "ok");
+ }
+
+ // Attach EID response headers when consent-gated EIDs are available.
+ // `Some(empty)` means "we looked and found no synced partners" — the
+ // header is still set (with an encoded empty array) so clients can
+ // distinguish this from `None` (EIDs not checked / consent denied).
+ if let Some(ref eids) = auction_request.user.eids {
+ let (encoded, truncated) = encode_eids_header(eids)?;
+ response.set_header(HEADER_X_TS_EIDS, encoded);
+ if truncated {
+ response.set_header(HEADER_X_TS_EIDS_TRUNCATED, "true");
+ }
+ }
+
+ Ok(response)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::auction::orchestrator::OrchestrationResult;
+ use crate::auction::types::{AdFormat, AdSlot, MediaType};
+ use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED};
+ use crate::openrtb::{Eid, Uid};
+
+ fn make_minimal_auction_request() -> AuctionRequest {
+ AuctionRequest {
+ id: "test-auction".to_owned(),
+ slots: vec![AdSlot {
+ id: "slot-1".to_owned(),
+ formats: vec![AdFormat {
+ media_type: MediaType::Banner,
+ width: 300,
+ height: 250,
+ }],
+ floor_price: None,
+ targeting: HashMap::new(),
+ bidders: HashMap::new(),
+ }],
+ publisher: PublisherInfo {
+ domain: "test.com".to_owned(),
+ page_url: None,
+ },
+ user: UserInfo {
+ id: Some("test-ec-id".to_owned()),
+ consent: None,
+ eids: None,
+ },
+ device: None,
+ site: None,
+ context: HashMap::new(),
+ }
+ }
+
+ fn make_empty_result() -> OrchestrationResult {
+ OrchestrationResult {
+ winning_bids: HashMap::new(),
+ provider_responses: Vec::new(),
+ mediator_response: None,
+ total_time_ms: 10,
+ metadata: HashMap::new(),
+ }
+ }
+
+ fn make_settings() -> Settings {
+ crate::test_support::tests::create_test_settings()
+ }
+
+ #[test]
+ fn response_includes_eid_headers_when_eids_present() {
+ let mut request = make_minimal_auction_request();
+ request.user.eids = Some(vec![Eid {
+ source: "ssp.com".to_owned(),
+ uids: vec![Uid {
+ id: "uid-1".to_owned(),
+ atype: Some(3),
+ ext: None,
+ }],
+ }]);
+
+ let settings = make_settings();
+ let result = make_empty_result();
+
+ let response = convert_to_openrtb_response(&result, &settings, &request, true)
+ .expect("should build response");
+
+ assert!(
+ response.get_header(HEADER_X_TS_EIDS).is_some(),
+ "should include x-ts-eids header when EIDs are present"
+ );
+ assert_eq!(
+ response
+ .get_header(HEADER_X_TS_EC_CONSENT)
+ .and_then(|v| v.to_str().ok()),
+ Some("ok"),
+ "should include x-ts-ec-consent: ok when ec_allowed is true"
+ );
+ assert!(
+ response.get_header(HEADER_X_TS_EIDS_TRUNCATED).is_none(),
+ "should not include truncated header for small payload"
+ );
+ }
+
+ #[test]
+ fn response_sets_consent_header_even_without_eids() {
+ let request = make_minimal_auction_request();
+ let settings = make_settings();
+ let result = make_empty_result();
+
+ let response = convert_to_openrtb_response(&result, &settings, &request, true)
+ .expect("should build response");
+
+ assert_eq!(
+ response
+ .get_header(HEADER_X_TS_EC_CONSENT)
+ .and_then(|v| v.to_str().ok()),
+ Some("ok"),
+ "should set x-ts-ec-consent: ok based on consent, not EID presence"
+ );
+ assert!(
+ response.get_header(HEADER_X_TS_EIDS).is_none(),
+ "should omit x-ts-eids when no EIDs available"
+ );
+ }
+
+ #[test]
+ fn response_omits_consent_header_when_not_allowed() {
+ let request = make_minimal_auction_request();
+ let settings = make_settings();
+ let result = make_empty_result();
+
+ let response = convert_to_openrtb_response(&result, &settings, &request, false)
+ .expect("should build response");
+
+ assert!(
+ response.get_header(HEADER_X_TS_EC_CONSENT).is_none(),
+ "should omit x-ts-ec-consent when ec_allowed is false"
+ );
+ assert!(
+ response.get_header(HEADER_X_TS_EIDS).is_none(),
+ "should omit x-ts-eids when no EIDs available"
+ );
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "should not emit x-ts-ec when a valid EC is present"
+ );
+ }
+
+ #[test]
+ fn response_omits_ec_header_when_ec_id_is_none() {
+ let mut request = make_minimal_auction_request();
+ request.user.id = None;
+
+ let settings = make_settings();
+ let result = make_empty_result();
+
+ let response = convert_to_openrtb_response(&result, &settings, &request, false)
+ .expect("should build response");
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "should omit x-ts-ec when no EC ID is available"
+ );
+ }
}
diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs
index 9cbcd2b9..15ad3221 100644
--- a/crates/trusted-server-core/src/auction/orchestrator.rs
+++ b/crates/trusted-server-core/src/auction/orchestrator.rs
@@ -1,13 +1,12 @@
//! Auction orchestrator for managing multi-provider auctions.
use error_stack::{Report, ResultExt};
+use fastly::http::request::{select, PendingRequest};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::error::TrustedServerError;
-use crate::platform::{PlatformPendingRequest, RuntimeServices};
-use crate::proxy::platform_response_to_fastly;
use super::config::AuctionConfig;
use super::provider::AuctionProvider;
@@ -66,7 +65,6 @@ impl AuctionOrchestrator {
&self,
request: &AuctionRequest,
context: &AuctionContext<'_>,
- services: &RuntimeServices,
) -> Result> {
let start_time = Instant::now();
@@ -74,13 +72,12 @@ impl AuctionOrchestrator {
let (strategy_name, result) = if self.config.has_mediator() {
(
"parallel_mediation",
- self.run_parallel_mediation(request, context, services)
- .await?,
+ self.run_parallel_mediation(request, context).await?,
)
} else {
(
"parallel_only",
- self.run_parallel_only(request, context, services).await?,
+ self.run_parallel_only(request, context).await?,
)
};
@@ -105,12 +102,9 @@ impl AuctionOrchestrator {
&self,
request: &AuctionRequest,
context: &AuctionContext<'_>,
- services: &RuntimeServices,
) -> Result> {
let mediation_start = Instant::now();
- let provider_responses = self
- .run_providers_parallel(request, context, services)
- .await?;
+ let provider_responses = self.run_providers_parallel(request, context).await?;
let floor_prices = self.floor_prices_by_slot(request);
let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator {
@@ -128,6 +122,8 @@ impl AuctionOrchestrator {
let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms);
if remaining_ms == 0 {
+ // lgtm[rust/cleartext-logging]
+ // This warning reports timeout budget metadata only; no secret settings are logged.
log::warn!(
"Auction timeout ({}ms) exhausted during bidding phase — skipping mediator",
context.timeout_ms
@@ -145,7 +141,6 @@ impl AuctionOrchestrator {
let mediator_context = AuctionContext {
settings: context.settings,
request: context.request,
- client_info: context.client_info,
timeout_ms: remaining_ms,
provider_responses: Some(&provider_responses),
services: context.services,
@@ -158,25 +153,13 @@ impl AuctionOrchestrator {
message: format!("Mediator {} failed to launch", mediator.provider_name()),
})?;
- let platform_resp = services
- .http_client()
- .wait(PlatformPendingRequest::new(pending))
- .await
- .change_context(TrustedServerError::Auction {
- message: format!("Mediator {} request failed", mediator.provider_name()),
- })?;
- let backend_response = platform_response_to_fastly(platform_resp).change_context(
- TrustedServerError::Auction {
- message: format!(
- "Mediator {} returned an unsupported response body",
- mediator.provider_name()
- ),
- },
- )?;
+ let backend_response = pending.wait().change_context(TrustedServerError::Auction {
+ message: format!("Mediator {} request failed", mediator.provider_name()),
+ })?;
let response_time_ms = start_time.elapsed().as_millis() as u64;
let mediator_resp = mediator
- .parse_response(backend_response, response_time_ms)
+ .parse_response_with_context(backend_response, response_time_ms, &mediator_context)
.change_context(TrustedServerError::Auction {
message: format!("Mediator {} parse failed", mediator.provider_name()),
})?;
@@ -225,11 +208,8 @@ impl AuctionOrchestrator {
&self,
request: &AuctionRequest,
context: &AuctionContext<'_>,
- services: &RuntimeServices,
) -> Result> {
- let provider_responses = self
- .run_providers_parallel(request, context, services)
- .await?;
+ let provider_responses = self.run_providers_parallel(request, context).await?;
let floor_prices = self.floor_prices_by_slot(request);
let winning_bids = self.select_winning_bids(&provider_responses, &floor_prices);
@@ -244,14 +224,12 @@ impl AuctionOrchestrator {
/// Run all providers in parallel and collect responses.
///
- /// Uses [`RuntimeServices::http_client`] and
- /// [`crate::platform::PlatformHttpClient::select`] to process responses as
- /// they become ready, rather than waiting for each response sequentially.
+ /// Uses `fastly::http::request::select()` to process responses as they
+ /// become ready, rather than waiting for each response sequentially.
async fn run_providers_parallel(
&self,
request: &AuctionRequest,
context: &AuctionContext<'_>,
- services: &RuntimeServices,
) -> Result, Report> {
let provider_names = self.config.provider_names();
@@ -273,7 +251,7 @@ impl AuctionOrchestrator {
// Maps backend_name -> (provider_name, start_time, provider)
let mut backend_to_provider: HashMap =
HashMap::new();
- let mut pending_requests: Vec = Vec::new();
+ let mut pending_requests: Vec = Vec::new();
for provider_name in provider_names {
let provider = match self.providers.get(provider_name) {
@@ -300,6 +278,8 @@ impl AuctionOrchestrator {
let effective_timeout = remaining_ms.min(provider.timeout_ms());
if effective_timeout == 0 {
+ // lgtm[rust/cleartext-logging]
+ // This warning reports timeout budget metadata only; no secret settings are logged.
log::warn!(
"Auction timeout ({}ms) exhausted before launching '{}' — skipping",
context.timeout_ms,
@@ -325,7 +305,6 @@ impl AuctionOrchestrator {
let provider_context = AuctionContext {
settings: context.settings,
request: context.request,
- client_info: context.client_info,
timeout_ms: effective_timeout,
provider_responses: context.provider_responses,
services: context.services,
@@ -342,11 +321,10 @@ impl AuctionOrchestrator {
match provider.request_bids(request, &provider_context) {
Ok(pending) => {
backend_to_provider.insert(
- backend_name.clone(),
+ backend_name,
(provider.provider_name(), start_time, provider.as_ref()),
);
- pending_requests
- .push(PlatformPendingRequest::new(pending).with_backend_name(backend_name));
+ pending_requests.push(pending);
log::debug!(
"Request to '{}' launched successfully",
provider.provider_name()
@@ -363,6 +341,8 @@ impl AuctionOrchestrator {
}
let deadline = Duration::from_millis(u64::from(context.timeout_ms));
+ // lgtm[rust/cleartext-logging]
+ // This info log reports request counts and timeout budget only; no secret settings are logged.
log::info!(
"Launched {} concurrent requests, waiting for responses (timeout: {}ms)...",
pending_requests.len(),
@@ -381,54 +361,39 @@ impl AuctionOrchestrator {
let mut remaining = pending_requests;
while !remaining.is_empty() {
- let select_result = services
- .http_client()
- .select(remaining)
- .await
- .change_context(TrustedServerError::Auction {
- message: "HTTP select failed".to_string(),
- })?;
- remaining = select_result.remaining;
+ let (result, rest) = select(remaining);
+ remaining = rest;
- match select_result.ready {
- Ok(platform_response) => {
+ match result {
+ Ok(response) => {
// Identify the provider from the backend name
- let backend_name = platform_response.backend_name.clone().unwrap_or_default();
+ let backend_name = response.get_backend_name().unwrap_or_default().to_string();
if let Some((provider_name, start_time, provider)) =
backend_to_provider.remove(&backend_name)
{
let response_time_ms = start_time.elapsed().as_millis() as u64;
- match platform_response_to_fastly(platform_response) {
- Ok(response) => {
- match provider.parse_response(response, response_time_ms) {
- Ok(auction_response) => {
- log::info!(
- "Provider '{}' returned {} bids (status: {:?}, time: {}ms)",
- auction_response.provider,
- auction_response.bids.len(),
- auction_response.status,
- auction_response.response_time_ms
- );
- responses.push(auction_response);
- }
- Err(e) => {
- log::warn!(
- "Provider '{}' failed to parse response: {:?}",
- provider_name,
- e
- );
- responses.push(AuctionResponse::error(
- provider_name,
- response_time_ms,
- ));
- }
- }
+ match provider.parse_response_with_context(
+ response,
+ response_time_ms,
+ context,
+ ) {
+ Ok(auction_response) => {
+ log::info!(
+ "Provider '{}' returned {} bids (status: {:?}, time: {}ms)",
+ auction_response.provider,
+ auction_response.bids.len(),
+ auction_response.status,
+ auction_response.response_time_ms
+ );
+ responses.push(auction_response);
}
Err(e) => {
+ // lgtm[rust/cleartext-logging]
+ // This warning reports provider parse failures only; no secret values are logged.
log::warn!(
- "Provider '{}' returned an unsupported response body: {:?}",
+ "Provider '{}' failed to parse response: {:?}",
provider_name,
e
);
@@ -454,6 +419,8 @@ impl AuctionOrchestrator {
// Remaining PendingRequests are dropped, which abandons the
// in-flight HTTP calls on the Fastly host.
if auction_start.elapsed() >= deadline && !remaining.is_empty() {
+ // lgtm[rust/cleartext-logging]
+ // This warning reports timeout budget metadata only; no secret settings are logged.
log::warn!(
"Auction timeout ({}ms) reached, dropping {} remaining request(s)",
context.timeout_ms,
@@ -637,16 +604,6 @@ mod tests {
use crate::auction::types::{
AdFormat, AdSlot, AuctionRequest, Bid, MediaType, PublisherInfo, UserInfo,
};
-
- // All-None ClientInfo used across tests that don't need real IP/TLS data.
- // Defined as a const so &EMPTY_CLIENT_INFO has 'static lifetime, avoiding
- // the temporary-lifetime issue that arises with &ClientInfo::default().
- const EMPTY_CLIENT_INFO: crate::platform::ClientInfo = crate::platform::ClientInfo {
- client_ip: None,
- tls_protocol: None,
- tls_cipher: None,
- };
- use crate::platform::test_support::noop_services;
use crate::test_support::tests::crate_test_settings_str;
use fastly::Request;
use std::collections::{HashMap, HashSet};
@@ -685,9 +642,9 @@ mod tests {
page_url: Some("https://test.com/article".to_string()),
},
user: UserInfo {
- id: "user-123".to_string(),
- fresh_id: "fresh-456".to_string(),
+ id: Some("user-123".to_string()),
consent: None,
+ eids: None,
},
device: None,
site: None,
@@ -787,11 +744,9 @@ mod tests {
let request = create_test_auction_request();
let settings = create_test_settings();
let req = Request::get("https://test.com/test");
- let context = create_test_auction_context(&settings, &req, &EMPTY_CLIENT_INFO, 2000);
+ let context = create_test_auction_context(&settings, &req, 2000);
- let result = orchestrator
- .run_auction(&request, &context, &noop_services())
- .await;
+ let result = orchestrator.run_auction(&request, &context).await;
assert!(result.is_err());
let err = result.unwrap_err();
diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs
index cd3fcfc3..7e509043 100644
--- a/crates/trusted-server-core/src/auction/provider.rs
+++ b/crates/trusted-server-core/src/auction/provider.rs
@@ -44,6 +44,25 @@ pub trait AuctionProvider: Send + Sync {
response_time_ms: u64,
) -> Result>;
+ /// Parse the response with access to the original auction context.
+ ///
+ /// Providers that need request-local metadata while transforming responses
+ /// can override this method. The default preserves the existing
+ /// response-only provider contract.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the response cannot be parsed into a valid [`AuctionResponse`].
+ fn parse_response_with_context(
+ &self,
+ response: fastly::Response,
+ response_time_ms: u64,
+ context: &AuctionContext<'_>,
+ ) -> Result> {
+ let _ = context;
+ self.parse_response(response, response_time_ms)
+ }
+
/// Check if this provider supports a specific media type.
fn supports_media_type(&self, media_type: &super::types::MediaType) -> bool {
// By default, support banner ads
diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs
index 2c5b5438..bfcdd10d 100644
--- a/crates/trusted-server-core/src/auction/test_support.rs
+++ b/crates/trusted-server-core/src/auction/test_support.rs
@@ -3,7 +3,7 @@ use std::sync::LazyLock;
use fastly::Request;
use super::AuctionContext;
-use crate::platform::{test_support::noop_services, ClientInfo, RuntimeServices};
+use crate::platform::{test_support::noop_services, RuntimeServices};
use crate::settings::Settings;
static TEST_SERVICES: LazyLock = LazyLock::new(noop_services);
@@ -11,14 +11,12 @@ static TEST_SERVICES: LazyLock = LazyLock::new(noop_services);
pub(crate) fn create_test_auction_context<'a>(
settings: &'a Settings,
request: &'a Request,
- client_info: &'a ClientInfo,
timeout_ms: u32,
) -> AuctionContext<'a> {
let services: &'static RuntimeServices = &TEST_SERVICES;
AuctionContext {
settings,
request,
- client_info,
timeout_ms,
provider_responses: None,
services,
diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs
index 17db5990..9b74d89e 100644
--- a/crates/trusted-server-core/src/auction/types.rs
+++ b/crates/trusted-server-core/src/auction/types.rs
@@ -6,7 +6,7 @@ use std::collections::HashMap;
use crate::auction::context::ContextValue;
use crate::geo::GeoInfo;
-use crate::platform::{ClientInfo, RuntimeServices};
+use crate::platform::RuntimeServices;
use crate::settings::Settings;
/// Represents a unified auction request across all providers.
@@ -70,10 +70,10 @@ pub struct PublisherInfo {
/// Privacy-preserving user information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
- /// Stable EC ID (from cookie or freshly generated)
- pub id: String,
- /// Fresh ID for this session
- pub fresh_id: String,
+ /// Stable EC ID (from cookie or freshly generated).
+ /// `None` when EC is unavailable or consent denies it.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub id: Option,
/// Decoded consent context for this request.
///
/// Carries both raw consent strings (for `OpenRTB` forwarding) and decoded
@@ -82,6 +82,13 @@ pub struct UserInfo {
/// cookies/headers, not from stored data.
#[serde(skip)]
pub consent: Option,
+ /// Consent-gated Extended User IDs resolved from the KV identity graph.
+ ///
+ /// Populated by the auction handler from partner data when the user has
+ /// a valid EC and consent permits EID transmission. `None` when no EIDs
+ /// are available (no EC, consent denied, or KV read failure).
+ #[serde(skip)]
+ pub eids: Option>,
}
/// Device information from request.
@@ -103,7 +110,6 @@ pub struct SiteInfo {
pub struct AuctionContext<'a> {
pub settings: &'a Settings,
pub request: &'a Request,
- pub client_info: &'a ClientInfo,
pub timeout_ms: u32,
/// Provider responses from the bidding phase, used by mediators.
/// This is `None` for regular bidders and `Some` when calling a mediator.
diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs
index fa882044..088d27e8 100644
--- a/crates/trusted-server-core/src/auth.rs
+++ b/crates/trusted-server-core/src/auth.rs
@@ -230,7 +230,7 @@ mod tests {
#[test]
fn allow_admin_path_with_valid_credentials() {
let settings = create_test_settings();
- let mut req = build_request(Method::POST, "https://example.com/admin/keys/rotate");
+ let mut req = build_request(Method::POST, "https://example.com/_ts/admin/keys/rotate");
let token = STANDARD.encode("admin:admin-pass");
set_authorization(&mut req, &format!("Basic {token}"));
@@ -245,7 +245,7 @@ mod tests {
#[test]
fn challenge_admin_path_with_wrong_credentials() {
let settings = create_test_settings();
- let mut req = build_request(Method::POST, "https://example.com/admin/keys/rotate");
+ let mut req = build_request(Method::POST, "https://example.com/_ts/admin/keys/rotate");
let token = STANDARD.encode("admin:wrong");
set_authorization(&mut req, &format!("Basic {token}"));
@@ -258,7 +258,7 @@ mod tests {
#[test]
fn challenge_admin_path_with_missing_credentials() {
let settings = create_test_settings();
- let req = build_request(Method::POST, "https://example.com/admin/keys/rotate");
+ let req = build_request(Method::POST, "https://example.com/_ts/admin/keys/rotate");
let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs
index 54bdd5de..738d0e9f 100644
--- a/crates/trusted-server-core/src/compat.rs
+++ b/crates/trusted-server-core/src/compat.rs
@@ -142,9 +142,7 @@ pub fn set_fastly_ec_cookie(
response: &mut fastly::Response,
ec_id: &str,
) {
- if let Some(cookie) = crate::cookies::try_build_ec_cookie_value(settings, ec_id) {
- response.append_header(header::SET_COOKIE, cookie);
- }
+ crate::ec::cookies::set_ec_cookie(settings, response, ec_id);
}
/// Expire the EC ID cookie on a `fastly::Response`.
@@ -154,14 +152,7 @@ pub fn expire_fastly_ec_cookie(
settings: &crate::settings::Settings,
response: &mut fastly::Response,
) {
- response.append_header(
- header::SET_COOKIE,
- format!(
- "{}=; {}",
- crate::constants::COOKIE_TS_EC,
- crate::cookies::ec_cookie_attributes(settings, 0),
- ),
- );
+ crate::ec::cookies::expire_ec_cookie(settings, response);
}
#[cfg(test)]
@@ -347,7 +338,8 @@ mod tests {
let settings = crate::test_support::tests::create_test_settings();
let mut response = fastly::Response::new();
- set_fastly_ec_cookie(&settings, &mut response, "abc123.XyZ789");
+ let ec_id = format!("{}.Ab12z9", "a".repeat(64));
+ set_fastly_ec_cookie(&settings, &mut response, &ec_id);
let cookie = response
.get_header(header::SET_COOKIE)
@@ -356,8 +348,8 @@ mod tests {
assert_eq!(
cookie,
Some(format!(
- "ts-ec=abc123.XyZ789; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000",
- settings.publisher.cookie_domain
+ "ts-ec={ec_id}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age=31536000; HttpOnly",
+ settings.publisher.domain
)),
"should set expected EC cookie"
);
@@ -377,8 +369,8 @@ mod tests {
assert_eq!(
cookie,
Some(format!(
- "ts-ec=; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0",
- settings.publisher.cookie_domain
+ "ts-ec=; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age=0; HttpOnly",
+ settings.publisher.domain
)),
"should set expected expiry cookie"
);
diff --git a/crates/trusted-server-core/src/consent/gpp.rs b/crates/trusted-server-core/src/consent/gpp.rs
index 9d0e5c81..ffb770c2 100644
--- a/crates/trusted-server-core/src/consent/gpp.rs
+++ b/crates/trusted-server-core/src/consent/gpp.rs
@@ -71,11 +71,14 @@ pub fn decode_gpp_string(gpp_string: &str) -> Result Option {
}
}
+/// GPP section IDs that represent US state/national privacy sections.
+///
+/// Range 7–23 per the GPP v1 specification:
+/// 7=UsNat, 8=UsCa, 9=UsVa, 10=UsCo, 11=UsUt, 12=UsCt, 13=UsFl,
+/// 14=UsMt, 15=UsOr, 16=UsTx, 17=UsDe, 18=UsIa, 19=UsNe, 20=UsNh,
+/// 21=UsNj, 22=UsTn, 23=UsMn.
+const US_SECTION_ID_RANGE: std::ops::RangeInclusive = 7..=23;
+
+/// Extracts the `sale_opt_out` signal across all US sections in a parsed GPP
+/// string.
+///
+/// Iterates through section IDs looking for any in the US range (7–23),
+/// decodes each US section, and aggregates the result conservatively:
+///
+/// - `Some(true)` if any decodable US section says the user opted out of sale
+/// - `Some(false)` if at least one decodable US section says they did not opt
+/// out and none say they opted out
+/// - `None` if no US section is present or no decodable US section yields a
+/// usable `sale_opt_out` signal
+fn decode_us_sale_opt_out(parsed: &iab_gpp::v1::GPPString) -> Option {
+ let mut result = None;
+
+ for us_section_id in parsed
+ .section_ids()
+ .filter(|id| US_SECTION_ID_RANGE.contains(&(**id as u16)))
+ {
+ match parsed.decode_section(*us_section_id) {
+ Ok(section) => match us_sale_opt_out_from_section(§ion) {
+ Some(true) => return Some(true),
+ Some(false) => result = Some(false),
+ None => {}
+ },
+ Err(e) => {
+ log::warn!("Failed to decode US GPP section {us_section_id}: {e}");
+ }
+ }
+ }
+
+ result
+}
+
+fn us_sale_opt_out_from_section(section: &iab_gpp::sections::Section) -> Option {
+ use iab_gpp::sections::us_common::OptOut;
+ use iab_gpp::sections::Section;
+
+ // Keep this match in sync with new US-state variants added by `iab_gpp`.
+ let sale_opt_out = match section {
+ Section::UsNat(s) => match &s.core {
+ iab_gpp::sections::usnat::Core::V1(c) => &c.sale_opt_out,
+ iab_gpp::sections::usnat::Core::V2(c) => &c.sale_opt_out,
+ _ => return None,
+ },
+ Section::UsCa(s) => &s.core.sale_opt_out,
+ Section::UsVa(s) => &s.core.sale_opt_out,
+ Section::UsCo(s) => &s.core.sale_opt_out,
+ Section::UsUt(s) => &s.core.sale_opt_out,
+ Section::UsCt(s) => &s.core.sale_opt_out,
+ Section::UsFl(s) => &s.core.sale_opt_out,
+ Section::UsMt(s) => &s.core.sale_opt_out,
+ Section::UsOr(s) => &s.core.sale_opt_out,
+ Section::UsTx(s) => &s.core.sale_opt_out,
+ Section::UsDe(s) => &s.core.sale_opt_out,
+ Section::UsIa(s) => &s.core.sale_opt_out,
+ Section::UsNe(s) => &s.core.sale_opt_out,
+ Section::UsNh(s) => &s.core.sale_opt_out,
+ Section::UsNj(s) => &s.core.sale_opt_out,
+ Section::UsTn(s) => &s.core.sale_opt_out,
+ Section::UsMn(s) => &s.core.sale_opt_out,
+ _ => return None,
+ };
+
+ Some(*sale_opt_out == OptOut::OptedOut)
+}
+
/// Parses a `__gpp_sid` cookie value into a vector of section IDs.
///
/// The cookie is a comma-separated list of integer section IDs, e.g. `"2,6"`.
@@ -239,4 +316,154 @@ mod tests {
"all-invalid should be None"
);
}
+
+ #[test]
+ fn decodes_us_sale_opt_out_not_opted_out() {
+ let result = decode_gpp_string("DBABLA~BVQqAAAAAgA.QA");
+ match &result {
+ Ok(gpp) => {
+ assert_eq!(
+ gpp.us_sale_opt_out,
+ Some(false),
+ "should extract sale_opt_out=false from UsNat section"
+ );
+ }
+ Err(e) => {
+ panic!("GPP decode failed: {e}");
+ }
+ }
+ }
+
+ fn encode_fibonacci_integer(mut value: u16) -> String {
+ let mut fibs = vec![1_u16];
+ let mut next = 2_u16;
+ while next <= value {
+ fibs.push(next);
+ next = if fibs.len() == 1 {
+ 2
+ } else {
+ fibs[fibs.len() - 1] + fibs[fibs.len() - 2]
+ };
+ }
+
+ let mut bits = vec![false; fibs.len()];
+ for (idx, fib) in fibs.iter().enumerate().rev() {
+ if *fib <= value {
+ value -= *fib;
+ bits[idx] = true;
+ }
+ }
+ bits.push(true);
+
+ bits.into_iter()
+ .map(|bit| if bit { '1' } else { '0' })
+ .collect()
+ }
+
+ fn encode_header(section_ids: &[u16]) -> String {
+ const BASE64_URL: &[u8; 64] =
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+ let mut bits = String::from("000011000001");
+ bits.push_str(&format!("{:012b}", section_ids.len()));
+
+ let mut previous = 0_u16;
+ for §ion_id in section_ids {
+ bits.push('0');
+ bits.push_str(&encode_fibonacci_integer(section_id - previous));
+ previous = section_id;
+ }
+
+ while bits.len() % 6 != 0 {
+ bits.push('0');
+ }
+
+ bits.as_bytes()
+ .chunks(6)
+ .map(|chunk| {
+ let value = u8::from_str_radix(
+ core::str::from_utf8(chunk).expect("should encode header bits as utf8"),
+ 2,
+ )
+ .expect("should parse 6-bit chunk");
+ char::from(BASE64_URL[value as usize])
+ })
+ .collect()
+ }
+
+ fn gpp_with_sections(sections: &[(u16, &str)]) -> String {
+ let ids = sections.iter().map(|(id, _)| *id).collect::>();
+ let header = encode_header(&ids);
+ let section_payloads = sections.iter().map(|(_, raw)| *raw).collect::>();
+ format!("{header}~{}", section_payloads.join("~"))
+ }
+
+ #[test]
+ fn no_us_section_returns_none() {
+ let result = decode_gpp_string(GPP_TCF_AND_USP).expect("should decode GPP");
+ assert_eq!(
+ result.us_sale_opt_out, None,
+ "should return None when no US section (7-23) is present"
+ );
+ }
+
+ #[test]
+ fn later_us_section_opt_out_overrides_earlier_non_opt_out() {
+ let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVVVVVVVVWA.AA")]);
+
+ let result = decode_gpp_string(&gpp).expect("should decode multi-section US GPP");
+
+ assert_eq!(
+ result.us_sale_opt_out,
+ Some(true),
+ "should treat any later decodable opt-out as authoritative"
+ );
+ }
+
+ #[test]
+ fn multiple_us_sections_without_opt_out_return_false() {
+ let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "BVgVVVVVVWA.AA")]);
+
+ let result = decode_gpp_string(&gpp).expect("should decode multi-section US GPP");
+
+ assert_eq!(
+ result.us_sale_opt_out,
+ Some(false),
+ "should return false when decodable US sections consistently do not opt out"
+ );
+ }
+
+ #[test]
+ fn valid_opt_out_wins_even_if_another_us_section_is_undecodable() {
+ let gpp = gpp_with_sections(&[(7, "BVQqAAAAAgA.QA"), (9, "not-a-valid-usva-section")]);
+
+ let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections");
+
+ assert_eq!(
+ result.us_sale_opt_out,
+ Some(false),
+ "should keep a valid non-opt-out signal even when another US section fails to decode"
+ );
+
+ let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "BVVVVVVVVWA.AA")]);
+ let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections");
+
+ assert_eq!(
+ result.us_sale_opt_out,
+ Some(true),
+ "should let a valid opt-out win even when another US section fails to decode"
+ );
+ }
+
+ #[test]
+ fn only_undecodable_us_sections_return_none() {
+ let gpp = gpp_with_sections(&[(7, "not-a-valid-usnat-section"), (9, "also-invalid")]);
+
+ let result = decode_gpp_string(&gpp).expect("should decode GPP header with raw sections");
+
+ assert_eq!(
+ result.us_sale_opt_out, None,
+ "should return None when no decodable US section yields sale_opt_out"
+ );
+ }
}
diff --git a/crates/trusted-server-core/src/consent/jurisdiction.rs b/crates/trusted-server-core/src/consent/jurisdiction.rs
index df0c5b59..bcc825c5 100644
--- a/crates/trusted-server-core/src/consent/jurisdiction.rs
+++ b/crates/trusted-server-core/src/consent/jurisdiction.rs
@@ -100,6 +100,7 @@ mod tests {
longitude: 0.0,
metro_code: 0,
region: region.map(str::to_owned),
+ asn: None,
}
}
diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs
index 36e7e628..d5aa5fbe 100644
--- a/crates/trusted-server-core/src/consent/mod.rs
+++ b/crates/trusted-server-core/src/consent/mod.rs
@@ -4,8 +4,13 @@
//!
//! 1. **Extract** raw consent strings from cookies and HTTP headers.
//! 2. **Decode** each signal into structured data (TCF v2, GPP, US Privacy).
-//! 3. **Build** a normalized [`ConsentContext`] that flows through the auction
-//! pipeline and populates `OpenRTB` bid requests.
+//! 3. **Build** a request-local [`ConsentContext`] that flows through the
+//! auction pipeline and populates `OpenRTB` bid requests.
+//!
+//! Consent is interpreted from request cookies, headers, geolocation, and
+//! publisher policy defaults. The consent pipeline does not read from or write
+//! to KV storage; EC identity lifecycle state is managed separately by the EC
+//! identity graph.
//!
//! # Supported signals
//!
@@ -22,8 +27,6 @@
//! req: &req,
//! config: &settings.consent,
//! geo: geo.as_ref(),
-//! ec_id: Some("ec_abc123"),
-//! kv_store: Some(runtime_services.kv_store()),
//! });
//! ```
@@ -34,7 +37,6 @@ pub mod tcf;
pub mod types;
pub mod us_privacy;
-pub use crate::storage::kv_store as kv;
pub use extraction::extract_consent_signals;
pub use types::{
ConsentContext, ConsentSource, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy,
@@ -68,17 +70,6 @@ pub struct ConsentPipelineInput<'a> {
pub config: &'a ConsentConfig,
/// Geolocation data from the request (for jurisdiction detection).
pub geo: Option<&'a GeoInfo>,
- /// EC ID for KV Store consent persistence.
- ///
- /// When set along with `kv_store`, enables:
- /// - **Read fallback**: loads consent from KV when cookies are absent.
- /// - **Write-on-change**: persists cookie-sourced consent to KV.
- pub ec_id: Option<&'a str>,
- /// KV store for consent persistence.
- ///
- /// `None` when consent persistence is not configured for this request, or
- /// when the caller intentionally skips consent KV access.
- pub kv_store: Option<&'a dyn crate::platform::PlatformKvStore>,
}
/// Extracts, decodes, and normalizes consent signals from a request.
@@ -93,6 +84,10 @@ pub struct ConsentPipelineInput<'a> {
/// 6. Builds a [`ConsentContext`] with both raw and decoded data.
/// 7. Logs a summary for observability.
///
+/// The returned context reflects request-local consent signals plus policy
+/// defaults only. This function does not load persisted consent from KV and
+/// does not persist consent to KV.
+///
/// Decoding failures are logged and the corresponding decoded field is set to
/// `None` — the raw string is still preserved for proxy-mode forwarding.
pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext {
@@ -126,24 +121,12 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext
};
}
- // KV Store fallback: if no cookie-based signals exist, try loading
- // persisted consent from the KV Store.
- if should_try_kv_fallback(&signals) {
- if let Some(ctx) = try_kv_fallback(input) {
- log_consent_context(&ctx);
- return ctx;
- }
- }
-
let mut ctx = build_context_from_signals(&signals);
ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config);
apply_tcf_conflict_resolution(&mut ctx, input.config);
apply_expiration_check(&mut ctx, input.config);
apply_gpc_us_privacy(&mut ctx, input.config);
- // KV Store write: persist cookie-sourced consent for future requests.
- try_kv_write(input, &ctx);
-
log_consent_context(&ctx);
ctx
}
@@ -171,6 +154,8 @@ fn apply_expiration_check(ctx: &mut ConsentContext, config: &ConsentConfig) {
return;
}
+ // lgtm[rust/cleartext-logging]
+ // This warning logs consent age metadata only; no raw consent string is emitted.
log::warn!(
"TCF consent expired (age: {age_days}d, max: {}d)",
config.max_consent_age_days
@@ -436,9 +421,8 @@ pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option(
eids: Option>,
@@ -482,8 +466,12 @@ pub fn gate_eids_by_consent(
/// 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 {
@@ -495,64 +483,65 @@ 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;
+ }
+ // When a CMP uses TCF in the US (e.g. Didomi), respect the
+ // TCF Purpose 1 decision — this is an explicit opt-in signal.
+ // The Sourcepoint GPP design documents this precedence decision.
+ if let Some(tcf) = effective_tcf(ctx) {
+ return tcf.has_storage_consent();
+ }
+ // Check GPP US section for sale opt-out.
+ if let Some(gpp) = &ctx.gpp {
+ if let Some(opted_out) = gpp.us_sale_opt_out {
+ return !opted_out;
+ }
+ }
+ // 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 string — fall back to GPC signal.
- !ctx.gpc
+ return usp.opt_out_sale != PrivacyFlag::Yes;
}
+ // Spec §6.1.1: "In regulated jurisdictions (GDPR, US state),
+ // consent cookies/headers must be present for
+ // allows_ec_creation() to return true." No signals = block.
+ false
}
- 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,
}
}
-// ---------------------------------------------------------------------------
-// KV Store integration helpers
-// ---------------------------------------------------------------------------
-
-/// Returns whether KV fallback should be attempted for this request.
+/// Returns `true` only when the request contains an explicit EC opt-out signal.
///
-/// KV fallback is used only when cookie-based consent signals are absent.
-/// A standalone `Sec-GPC` header should not suppress fallback reads.
+/// This is intentionally narrower than [`allows_ec_creation`]. Some requests
+/// fail closed because consent cannot be verified yet (for example, missing geo
+/// or missing/undecodable consent signals in a regulated jurisdiction). Those
+/// cases must block *new* EC creation, but they must not be treated as an
+/// authoritative withdrawal of an already-issued EC.
#[must_use]
-fn should_try_kv_fallback(signals: &RawConsentSignals) -> bool {
- !signals.has_cookie_signals()
-}
-
-/// Attempts to load consent from the KV Store when cookie signals are empty.
-///
-/// Returns `Some(ConsentContext)` if a valid entry was found and decoded,
-/// `None` otherwise. Requires both `kv_store` and `ec_id` to be present.
-fn try_kv_fallback(input: &ConsentPipelineInput<'_>) -> Option {
- let kv_store = input.kv_store?;
- let ec_id = input.ec_id?;
-
- log::debug!("No cookie consent signals, trying KV fallback for '{ec_id}'");
- let mut ctx = kv::load_consent_from_kv(kv_store, ec_id)?;
-
- // Re-detect jurisdiction from current geo (may differ from stored value).
- ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config);
- apply_tcf_conflict_resolution(&mut ctx, input.config);
- apply_expiration_check(&mut ctx, input.config);
- apply_gpc_us_privacy(&mut ctx, input.config);
-
- Some(ctx)
-}
-
-/// Persists cookie-sourced consent to the KV Store when configured.
-///
-/// Only writes when consent signals are non-empty and have changed since
-/// the last write (fingerprint comparison).
-fn try_kv_write(input: &ConsentPipelineInput<'_>, ctx: &ConsentContext) {
- let Some(kv_store) = input.kv_store else {
- return;
- };
- let Some(ec_id) = input.ec_id else {
- return;
- };
-
- kv::save_consent_to_kv(kv_store, ec_id, ctx, input.config.max_consent_age_days);
+pub fn has_explicit_ec_withdrawal(ctx: &ConsentContext) -> bool {
+ match &ctx.jurisdiction {
+ jurisdiction::Jurisdiction::Gdpr => {
+ effective_tcf(ctx).is_some_and(|tcf| !tcf.has_storage_consent())
+ }
+ jurisdiction::Jurisdiction::UsState(_) => {
+ if ctx.gpc {
+ return true;
+ }
+ if let Some(tcf) = effective_tcf(ctx) {
+ return !tcf.has_storage_consent();
+ }
+ ctx.us_privacy
+ .as_ref()
+ .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes)
+ }
+ jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => false,
+ }
}
// ---------------------------------------------------------------------------
@@ -618,7 +607,7 @@ mod tests {
use super::{
allows_ec_creation, apply_expiration_check, apply_tcf_conflict_resolution,
- build_consent_context, build_context_from_signals, should_try_kv_fallback,
+ build_consent_context, build_context_from_signals, has_explicit_ec_withdrawal,
ConsentPipelineInput,
};
use crate::consent::jurisdiction::Jurisdiction;
@@ -714,38 +703,12 @@ mod tests {
version: 1,
section_ids: vec![2],
eu_tcf: Some(make_tcf(gpp_last_updated_ds, gpp_allows_eids)),
+ us_sale_opt_out: None,
}),
..ConsentContext::default()
}
}
- #[test]
- fn kv_fallback_allowed_when_only_gpc_present() {
- let signals = RawConsentSignals {
- gpc: true,
- ..RawConsentSignals::default()
- };
-
- assert!(
- should_try_kv_fallback(&signals),
- "should allow KV fallback when only Sec-GPC is present"
- );
- }
-
- #[test]
- fn kv_fallback_skipped_when_cookie_signal_present() {
- let signals = RawConsentSignals {
- raw_tc_string: Some("CPXxGfAPXxGfA".to_owned()),
- gpc: true,
- ..RawConsentSignals::default()
- };
-
- assert!(
- !should_try_kv_fallback(&signals),
- "should skip KV fallback when cookie signals are present"
- );
- }
-
#[test]
fn proxy_mode_marks_gdpr_when_raw_tc_exists() {
let jar = parse_cookies_to_jar("euconsent-v2=CPXxGfAPXxGfA");
@@ -760,8 +723,6 @@ mod tests {
req: &req,
config: &config,
geo: None,
- ec_id: None,
- kv_store: None,
});
assert!(
@@ -790,8 +751,6 @@ mod tests {
req: &req,
config: &config,
geo: None,
- ec_id: None,
- kv_store: None,
});
assert!(
@@ -877,6 +836,7 @@ mod tests {
version: 1,
section_ids: vec![2],
eu_tcf: Some(make_tcf(0, true)),
+ us_sale_opt_out: None,
}),
..ConsentContext::default()
};
@@ -959,6 +919,7 @@ mod tests {
version: 1,
section_ids: vec![2],
eu_tcf: Some(make_tcf_with_storage(true)),
+ us_sale_opt_out: None,
}),
gdpr_applies: true,
..ConsentContext::default()
@@ -1020,7 +981,7 @@ mod tests {
}
#[test]
- fn ec_allowed_us_state_no_signals() {
+ fn ec_blocked_us_state_no_signals() {
let ctx = ConsentContext {
jurisdiction: Jurisdiction::UsState("CA".to_owned()),
us_privacy: None,
@@ -1028,8 +989,8 @@ mod tests {
..ConsentContext::default()
};
assert!(
- allows_ec_creation(&ctx),
- "US state + no opt-out signals should allow EC (opt-out model)"
+ !allows_ec_creation(&ctx),
+ "US state + no consent signals should block EC (spec §6.1.1: fail-closed)"
);
}
@@ -1046,14 +1007,41 @@ 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)"
+ );
+ assert!(
+ !has_explicit_ec_withdrawal(&ctx),
+ "unknown jurisdiction should not be treated as an explicit withdrawal"
+ );
+ }
+
+ #[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"
+ );
+ assert!(
+ has_explicit_ec_withdrawal(&ctx),
+ "GPC=true should be treated as an explicit withdrawal signal"
);
}
@@ -1074,4 +1062,185 @@ mod tests {
"US Privacy with opt_out=N/A should allow EC"
);
}
+
+ #[test]
+ fn ec_allowed_us_state_tcf_with_storage_consent() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ tcf: Some(make_tcf_with_storage(true)),
+ ..ConsentContext::default()
+ };
+ assert!(
+ allows_ec_creation(&ctx),
+ "US state + TCF Purpose 1 consented should allow EC (Didomi-style CMP)"
+ );
+ }
+
+ #[test]
+ fn ec_blocked_us_state_tcf_without_storage_consent() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ tcf: Some(make_tcf_with_storage(false)),
+ ..ConsentContext::default()
+ };
+ assert!(
+ !allows_ec_creation(&ctx),
+ "US state + TCF Purpose 1 denied should block EC"
+ );
+ }
+
+ #[test]
+ fn ec_blocked_us_state_gpc_overrides_tcf() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ tcf: Some(make_tcf_with_storage(true)),
+ gpc: true,
+ ..ConsentContext::default()
+ };
+ assert!(
+ !allows_ec_creation(&ctx),
+ "GPC should block EC even when TCF grants storage consent in US state"
+ );
+ }
+
+ #[test]
+ fn ec_allowed_us_state_tcf_takes_priority_over_us_privacy() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("CA".to_owned()),
+ tcf: Some(make_tcf_with_storage(true)),
+ us_privacy: Some(UsPrivacy {
+ version: 1,
+ notice_given: PrivacyFlag::Yes,
+ opt_out_sale: PrivacyFlag::Yes,
+ lspa_covered: PrivacyFlag::NotApplicable,
+ }),
+ ..ConsentContext::default()
+ };
+ assert!(
+ allows_ec_creation(&ctx),
+ "TCF consent should take priority over US Privacy opt-out when both present"
+ );
+ }
+
+ #[test]
+ fn ec_allowed_us_state_gpp_no_sale_opt_out() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ gpp: Some(GppConsent {
+ version: 1,
+ section_ids: vec![7],
+ eu_tcf: None,
+ us_sale_opt_out: Some(false),
+ }),
+ ..ConsentContext::default()
+ };
+ assert!(
+ allows_ec_creation(&ctx),
+ "US state + GPP US sale_opt_out=false should allow EC"
+ );
+ }
+
+ #[test]
+ fn ec_blocked_us_state_gpp_sale_opted_out() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ gpp: Some(GppConsent {
+ version: 1,
+ section_ids: vec![7],
+ eu_tcf: None,
+ us_sale_opt_out: Some(true),
+ }),
+ ..ConsentContext::default()
+ };
+ assert!(
+ !allows_ec_creation(&ctx),
+ "US state + GPP US sale_opt_out=true should block EC"
+ );
+ }
+
+ #[test]
+ fn ec_blocked_us_state_gpc_overrides_gpp_us() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ gpc: true,
+ gpp: Some(GppConsent {
+ version: 1,
+ section_ids: vec![7],
+ eu_tcf: None,
+ us_sale_opt_out: Some(false),
+ }),
+ ..ConsentContext::default()
+ };
+ assert!(
+ !allows_ec_creation(&ctx),
+ "GPC should block EC even when GPP US says no opt-out"
+ );
+ }
+
+ #[test]
+ fn ec_us_state_tcf_takes_priority_over_gpp_us() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ tcf: Some(make_tcf_with_storage(true)),
+ gpp: Some(GppConsent {
+ version: 1,
+ section_ids: vec![7],
+ eu_tcf: None,
+ us_sale_opt_out: Some(true),
+ }),
+ ..ConsentContext::default()
+ };
+ assert!(
+ allows_ec_creation(&ctx),
+ "TCF consent should take priority over GPP US opt-out"
+ );
+ }
+
+ #[test]
+ fn ec_us_state_gpp_us_takes_priority_over_us_privacy() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("TN".to_owned()),
+ gpp: Some(GppConsent {
+ version: 1,
+ section_ids: vec![7],
+ eu_tcf: None,
+ us_sale_opt_out: Some(false),
+ }),
+ us_privacy: Some(UsPrivacy {
+ version: 1,
+ notice_given: PrivacyFlag::Yes,
+ opt_out_sale: PrivacyFlag::Yes,
+ lspa_covered: PrivacyFlag::NotApplicable,
+ }),
+ ..ConsentContext::default()
+ };
+ assert!(
+ allows_ec_creation(&ctx),
+ "GPP US should take priority over us_privacy opt-out"
+ );
+ }
+
+ #[test]
+ fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("CA".to_owned()),
+ gpp: Some(GppConsent {
+ version: 1,
+ section_ids: vec![2],
+ eu_tcf: None,
+ us_sale_opt_out: None,
+ }),
+ us_privacy: Some(UsPrivacy {
+ version: 1,
+ notice_given: PrivacyFlag::Yes,
+ opt_out_sale: PrivacyFlag::No,
+ lspa_covered: PrivacyFlag::NotApplicable,
+ }),
+ ..ConsentContext::default()
+ };
+ assert!(
+ allows_ec_creation(&ctx),
+ "GPP without US section should fall through to us_privacy"
+ );
+ }
}
diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs
index a68eda9a..44f1a3df 100644
--- a/crates/trusted-server-core/src/consent/types.rs
+++ b/crates/trusted-server-core/src/consent/types.rs
@@ -302,6 +302,13 @@ pub struct GppConsent {
pub section_ids: Vec,
/// Decoded EU TCF v2.2 section (if present in GPP, section ID 2).
pub eu_tcf: Option,
+ /// Whether the user opted out of sale of personal information via a US GPP
+ /// section (IDs 7–23).
+ ///
+ /// - `Some(true)` — a US section is present and `sale_opt_out == OptedOut`
+ /// - `Some(false)` — a US section is present and user did not opt out
+ /// - `None` — no US section exists in the GPP string
+ pub us_sale_opt_out: Option,
}
// ---------------------------------------------------------------------------
diff --git a/crates/trusted-server-core/src/consent_config.rs b/crates/trusted-server-core/src/consent_config.rs
index e2074c48..e5fed1a9 100644
--- a/crates/trusted-server-core/src/consent_config.rs
+++ b/crates/trusted-server-core/src/consent_config.rs
@@ -72,14 +72,6 @@ pub struct ConsentConfig {
/// but disagree on consent status.
#[serde(default)]
pub conflict_resolution: ConflictResolutionConfig,
-
- /// Name of the KV Store used for consent persistence.
- ///
- /// When set, consent data is persisted per Edge Cookie (EC) ID so that
- /// returning users without consent cookies can still have their
- /// consent preferences applied. Set to `None` to disable.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub consent_store: Option,
}
impl Default for ConsentConfig {
@@ -92,7 +84,6 @@ impl Default for ConsentConfig {
us_states: UsStatesConfig::default(),
us_privacy_defaults: UsPrivacyDefaultsConfig::default(),
conflict_resolution: ConflictResolutionConfig::default(),
- consent_store: None,
}
}
}
diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs
index 0ee9fc76..0dec1dae 100644
--- a/crates/trusted-server-core/src/constants.rs
+++ b/crates/trusted-server-core/src/constants.rs
@@ -1,10 +1,14 @@
use http::header::HeaderName;
pub const COOKIE_TS_EC: &str = "ts-ec";
+pub const COOKIE_TS_EIDS: &str = "ts-eids";
+pub const COOKIE_SHAREDID: &str = "sharedId";
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_TS_EIDS: HeaderName = HeaderName::from_static("x-ts-eids");
+pub const HEADER_X_TS_EC_CONSENT: HeaderName = HeaderName::from_static("x-ts-ec-consent");
+pub const HEADER_X_TS_EIDS_TRUNCATED: HeaderName = HeaderName::from_static("x-ts-eids-truncated");
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");
@@ -45,7 +49,9 @@ 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-ts-eids",
+ "x-ts-ec-consent",
+ "x-ts-eids-truncated",
"x-pub-user-id",
"x-subject-id",
"x-consent-advertising",
diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs
index 67f4a4c7..e56c6834 100644
--- a/crates/trusted-server-core/src/cookies.rs
+++ b/crates/trusted-server-core/src/cookies.rs
@@ -1,22 +1,16 @@
//! Cookie handling utilities.
//!
-//! This module provides functionality for parsing and creating cookies
+//! This module provides functionality for parsing, stripping, and forwarding cookies
//! used in the trusted server system.
-use std::borrow::Cow;
-
use cookie::{Cookie, CookieJar};
use edgezero_core::body::Body as EdgeBody;
use error_stack::{Report, ResultExt};
use http::header;
use http::Request;
-use http::Response;
-use crate::constants::{
- COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_TS_EC, COOKIE_US_PRIVACY,
-};
+use crate::constants::{COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_US_PRIVACY};
use crate::error::TrustedServerError;
-use crate::settings::Settings;
/// Cookie names carrying privacy consent signals.
///
@@ -30,50 +24,6 @@ pub const CONSENT_COOKIE_NAMES: &[&str] = &[
COOKIE_US_PRIVACY,
];
-const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; // 1 year
-
-fn is_allowed_ec_id_char(c: char) -> bool {
- c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
-}
-
-// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a
-// defense-in-depth backstop when setting the Set-Cookie header. This is
-// intentionally broader than the inbound format validator
-// (`synthetic::is_valid_synthetic_id`), which enforces the exact
-// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted
-// request values before they enter the system.
-#[must_use]
-pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool {
- ec_id.chars().all(is_allowed_ec_id_char)
-}
-
-fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> {
- if ec_id_has_only_allowed_chars(ec_id) {
- return Cow::Borrowed(ec_id);
- }
-
- let safe_id = ec_id
- .chars()
- .filter(|c| is_allowed_ec_id_char(*c))
- .collect::();
-
- log::warn!(
- "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \
- callers should reject invalid request IDs before cookie creation",
- ec_id.len(),
- safe_id.len(),
- );
-
- Cow::Owned(safe_id)
-}
-
-pub(crate) fn ec_cookie_attributes(settings: &Settings, max_age: i32) -> String {
- format!(
- "Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age}",
- settings.publisher.cookie_domain,
- )
-}
-
/// Parses a cookie string into a [`CookieJar`].
///
/// Returns an empty jar if the cookie string is unparseable.
@@ -172,7 +122,7 @@ pub fn forward_cookie_header(
}
}
Err(_) => {
- // Non-UTF-8 Cookie header — forward as-is
+ // Non-UTF-8 Cookie header — forward as-is.
to.headers_mut()
.append(header::COOKIE, cookie_value.clone());
}
@@ -180,152 +130,14 @@ pub fn forward_cookie_header(
}
}
-/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`.
-/// An empty string is always rejected.
-///
-/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace,
-/// double-quote, comma, semicolon, and backslash. Rejecting these characters
-/// prevents header-injection attacks where a crafted value could append
-/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`).
-///
-/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their
-/// byte values exceed `0x7E`.
-#[must_use]
-pub(crate) fn ec_cookie_value_is_safe(value: &str) -> bool {
- // RFC 6265 §4.1.1 cookie-octet:
- // 0x21 — '!'
- // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE)
- // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma)
- // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon)
- // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL)
- // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded.
- !value.is_empty()
- && value
- .bytes()
- .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E))
-}
-
-/// Generates a `Set-Cookie` header value with the following security attributes:
-/// - `Secure`: transmitted over HTTPS only.
-/// - `HttpOnly`: inaccessible to JavaScript (`document.cookie`), blocking XSS exfiltration.
-/// Safe to set because integrations receive the EC ID via the `x-ts-ec`
-/// response header instead of reading it from the cookie directly.
-/// - `SameSite=Lax`: sent on same-site requests and top-level cross-site navigations.
-/// `Strict` is intentionally avoided — it would suppress the cookie on the first
-/// request when a user arrives from an external page, breaking first-visit attribution.
-/// - `Max-Age`: 1 year retention.
-///
-/// The `ec_id` is sanitized via an allowlist before embedding in the cookie value.
-/// Only ASCII alphanumeric characters and `.`, `-`, `_` are permitted — matching the
-/// known EC ID format (`{64-char-hex}.{6-char-alphanumeric}`). Request-sourced IDs
-/// with disallowed characters are rejected earlier in [`crate::edge_cookie::get_ec_id`];
-/// this sanitization remains as a defense-in-depth backstop for unexpected callers.
-///
-/// The `cookie_domain` is validated at config load time via [`validator::Validate`] on
-/// [`crate::settings::Publisher`]; bad config fails at startup, not per-request.
-///
-/// # Examples
-///
-/// ```no_run
-/// # use trusted_server_core::cookies::create_ec_cookie;
-/// # use trusted_server_core::settings::Settings;
-/// // `settings` is loaded at startup via `Settings::from_toml_and_env`.
-/// # fn example(settings: &Settings) {
-/// let cookie = create_ec_cookie(settings, "abc123.xk92ab");
-/// assert!(cookie.contains("HttpOnly"));
-/// assert!(cookie.contains("Secure"));
-/// # }
-/// ```
-#[must_use]
-pub fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String {
- let safe_id = sanitize_ec_id_for_cookie(ec_id);
-
- format!(
- "{}={}; {}",
- COOKIE_TS_EC,
- safe_id,
- ec_cookie_attributes(settings, COOKIE_MAX_AGE),
- )
-}
-
-#[must_use]
-pub(crate) fn try_build_ec_cookie_value(settings: &Settings, ec_id: &str) -> Option {
- if !ec_cookie_value_is_safe(ec_id) {
- log::warn!(
- "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value",
- ec_id.len()
- );
- return None;
- }
-
- Some(create_ec_cookie(settings, ec_id))
-}
-
-/// Sets the EC ID cookie on the given response.
-///
-/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before
-/// interpolation. If the value contains unsafe characters (e.g. semicolons),
-/// the cookie is not set and a warning is logged. This prevents an attacker
-/// from injecting spurious cookie attributes via a controlled ID value.
-///
-/// `cookie_domain` comes from operator configuration and is considered trusted.
-///
-/// # Panics
-///
-/// Does not panic in practice — the cookie value is validated by
-/// [`ec_cookie_value_is_safe`] (early return if invalid) before
-/// [`http::HeaderValue::from_str`] is called, so the expect is unreachable.
-/// Listed here only because clippy cannot prove it statically.
-pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) {
- let Some(cookie) = try_build_ec_cookie_value(settings, ec_id) else {
- return;
- };
-
- response.headers_mut().append(
- header::SET_COOKIE,
- http::HeaderValue::from_str(&cookie).expect("should build Set-Cookie header value"),
- );
-}
-
-/// 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.
-///
-/// # Panics
-///
-/// Does not panic in practice — the formatted value contains only ASCII
-/// printable characters (constant name, validated domain, static attributes),
-/// so [`http::HeaderValue::from_str`] always succeeds. Listed here only
-/// because clippy cannot prove it statically.
-pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) {
- response.headers_mut().append(
- header::SET_COOKIE,
- http::HeaderValue::from_str(&format!(
- "{}=; {}",
- COOKIE_TS_EC,
- ec_cookie_attributes(settings, 0),
- ))
- .expect("should build expiry Set-Cookie header value"),
- );
-}
-
#[cfg(test)]
mod tests {
use http::HeaderValue;
use crate::error::TrustedServerError;
- use crate::test_support::tests::create_test_settings;
use super::*;
- fn build_response() -> Response {
- Response::builder()
- .status(200)
- .body(EdgeBody::empty())
- .expect("should build test response")
- }
-
fn build_request(cookie_header: Option<&str>) -> Request {
let mut builder = Request::builder().method("GET").uri("http://example.com");
if let Some(cookie_header) = cookie_header {
@@ -433,169 +245,6 @@ mod tests {
);
}
- #[test]
- fn test_set_ec_cookie() {
- let settings = create_test_settings();
- let mut response = build_response();
- set_ec_cookie(&settings, &mut response, "abc123.XyZ789");
-
- let cookie_str = response
- .headers()
- .get(header::SET_COOKIE)
- .expect("Set-Cookie header should be present")
- .to_str()
- .expect("header should be valid UTF-8");
-
- assert_eq!(
- cookie_str,
- format!(
- "{}=abc123.XyZ789; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={}",
- COOKIE_TS_EC, settings.publisher.cookie_domain, COOKIE_MAX_AGE,
- ),
- "Set-Cookie header should match expected format"
- );
- }
-
- #[test]
- fn test_create_ec_cookie_sanitizes_disallowed_chars_in_id() {
- let settings = create_test_settings();
- // Allowlist permits only ASCII alphanumeric, '.', '-', '_'.
- // ';', '=', '\r', '\n', spaces, NUL bytes, and other control chars are all stripped.
- let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz");
- // Extract the value portion anchored to the cookie name constant to
- // avoid false positives from disallowed chars in cookie attributes.
- 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, "evilinjectedfoobarbaz",
- "should strip disallowed characters and preserve safe chars"
- );
- }
-
- #[test]
- fn test_create_ec_cookie_preserves_well_formed_id() {
- let settings = create_test_settings();
- // A well-formed ID should pass through the allowlist unmodified.
- 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 test_set_ec_cookie_rejects_semicolon() {
- let settings = create_test_settings();
- let mut response = build_response();
- set_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com");
-
- assert!(
- response.headers().get(header::SET_COOKIE).is_none(),
- "Set-Cookie should not be set when value contains a semicolon"
- );
- }
-
- #[test]
- fn test_set_ec_cookie_rejects_crlf() {
- let settings = create_test_settings();
- let mut response = build_response();
- set_ec_cookie(&settings, &mut response, "evil\r\nX-Injected: header");
-
- assert!(
- response.headers().get(header::SET_COOKIE).is_none(),
- "Set-Cookie should not be set when value contains CRLF"
- );
- }
-
- #[test]
- fn test_set_ec_cookie_rejects_space() {
- let settings = create_test_settings();
- let mut response = build_response();
- set_ec_cookie(&settings, &mut response, "bad value");
-
- assert!(
- response.headers().get(header::SET_COOKIE).is_none(),
- "Set-Cookie should not be set when value contains whitespace"
- );
- }
-
- #[test]
- fn test_is_safe_cookie_value_rejects_empty_string() {
- assert!(!ec_cookie_value_is_safe(""), "should reject empty string");
- }
-
- #[test]
- fn test_is_safe_cookie_value_accepts_valid_ec_id_characters() {
- // Hex digits, dot separator, alphanumeric suffix — the full EC ID character set
- assert!(
- ec_cookie_value_is_safe("abcdef0123456789.ABCDEFabcdef"),
- "should accept hex digits, dots, and alphanumeric characters"
- );
- }
-
- #[test]
- fn test_is_safe_cookie_value_rejects_non_ascii() {
- assert!(
- !ec_cookie_value_is_safe("valüe"),
- "should reject non-ASCII UTF-8 characters"
- );
- }
-
- #[test]
- fn test_is_safe_cookie_value_rejects_illegal_characters() {
- assert!(
- !ec_cookie_value_is_safe("val;ue"),
- "should reject semicolon"
- );
- assert!(!ec_cookie_value_is_safe("val,ue"), "should reject comma");
- assert!(
- !ec_cookie_value_is_safe("val\"ue"),
- "should reject double-quote"
- );
- assert!(
- !ec_cookie_value_is_safe("val\\ue"),
- "should reject backslash"
- );
- assert!(!ec_cookie_value_is_safe("val ue"), "should reject space");
- assert!(
- !ec_cookie_value_is_safe("val\x00ue"),
- "should reject null byte"
- );
- assert!(
- !ec_cookie_value_is_safe("val\x7fue"),
- "should reject DEL character"
- );
- }
-
- #[test]
- fn test_expire_ec_cookie_matches_security_attributes() {
- let settings = create_test_settings();
- let mut response = build_response();
-
- expire_ec_cookie(&settings, &mut response);
-
- let cookie_header = response
- .headers()
- .get(header::SET_COOKIE)
- .expect("Set-Cookie header should be present");
- let cookie_str = cookie_header
- .to_str()
- .expect("header should be valid UTF-8");
-
- assert_eq!(
- cookie_str,
- format!(
- "{}=; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0",
- COOKIE_TS_EC, settings.publisher.cookie_domain,
- ),
- "expiry cookie should retain the same security attributes as the live cookie"
- );
- }
-
// ---------------------------------------------------------------
// forward_cookie_header tests
// ---------------------------------------------------------------
@@ -754,7 +403,7 @@ mod tests {
#[test]
fn test_strip_cookies_with_complex_values() {
- // Cookie values can contain '=' characters
+ // Cookie values can contain '=' characters.
let header = "euconsent-v2=BOE=xyz; session=abc=123=def";
let stripped = strip_cookies(header, CONSENT_COOKIE_NAMES);
assert_eq!(stripped, "session=abc=123=def");
diff --git a/crates/trusted-server-core/src/ec/auth.rs b/crates/trusted-server-core/src/ec/auth.rs
new file mode 100644
index 00000000..f41c8a2b
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/auth.rs
@@ -0,0 +1,110 @@
+//! Shared Bearer-token authentication helpers for EC partner endpoints.
+//!
+//! Used by both `/_ts/api/v1/identify` and `/_ts/api/v1/batch-sync` so
+//! authentication hardening stays consistent across endpoints.
+
+use fastly::Request;
+
+use super::partner::hash_api_key;
+use super::registry::{PartnerConfig, PartnerRegistry};
+
+/// Authenticates a request via Bearer token, returning the matching partner.
+pub(super) fn authenticate_bearer<'r>(
+ registry: &'r PartnerRegistry,
+ req: &Request,
+) -> Option<&'r PartnerConfig> {
+ let header_value = req.get_header_str("authorization")?;
+ let token = parse_bearer_token(header_value)?;
+ let key_hash = hash_api_key(token);
+ registry.find_by_api_key_hash(&key_hash)
+}
+
+fn parse_bearer_token(header_value: &str) -> Option<&str> {
+ let mut parts = header_value.split_whitespace();
+ let scheme = parts.next()?;
+ let token = parts.next()?;
+
+ if !scheme.eq_ignore_ascii_case("bearer") || token.is_empty() {
+ return None;
+ }
+ if parts.next().is_some() {
+ return None;
+ }
+
+ Some(token)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::redacted::Redacted;
+ use crate::settings::EcPartner;
+
+ const VALID_API_TOKEN: &str = "auth-test-token-32-bytes-minimum";
+
+ fn make_test_partner(id: &str, api_token: &str) -> EcPartner {
+ EcPartner {
+ id: id.to_owned(),
+ name: format!("Partner {id}"),
+ source_domain: format!("{id}.example.com"),
+ openrtb_atype: EcPartner::default_openrtb_atype(),
+ bidstream_enabled: true,
+ api_token: Redacted::new(api_token.to_owned()),
+ batch_rate_limit: EcPartner::default_batch_rate_limit(),
+ pull_sync_enabled: false,
+ pull_sync_url: None,
+ pull_sync_allowed_domains: vec![],
+ pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(),
+ pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(),
+ ts_pull_token: None,
+ }
+ }
+
+ #[test]
+ fn parse_bearer_token_accepts_case_insensitive_scheme() {
+ assert_eq!(parse_bearer_token("Bearer tok"), Some("tok"));
+ assert_eq!(parse_bearer_token("bearer tok"), Some("tok"));
+ assert_eq!(parse_bearer_token("BEARER tok"), Some("tok"));
+ }
+
+ #[test]
+ fn parse_bearer_token_rejects_invalid_shapes() {
+ assert_eq!(parse_bearer_token("Bearer"), None);
+ assert_eq!(parse_bearer_token("Bearer "), None);
+ assert_eq!(parse_bearer_token("Basic abc"), None);
+ assert_eq!(parse_bearer_token("Bearer a b"), None);
+ }
+
+ #[test]
+ fn authenticate_bearer_returns_none_for_missing_header() {
+ let registry = PartnerRegistry::empty();
+ let req = Request::new("GET", "https://edge.example.com/_ts/api/v1/identify");
+
+ let result = authenticate_bearer(®istry, &req);
+ assert!(result.is_none(), "should return None without auth header");
+ }
+
+ #[test]
+ fn authenticate_bearer_returns_none_for_malformed_header() {
+ let registry = PartnerRegistry::empty();
+ let mut req = Request::new("GET", "https://edge.example.com/_ts/api/v1/identify");
+ req.set_header("authorization", "Basic dXNlcjpwYXNz");
+
+ let result = authenticate_bearer(®istry, &req);
+ assert!(
+ result.is_none(),
+ "should return None for non-Bearer auth scheme"
+ );
+ }
+
+ #[test]
+ fn authenticate_bearer_returns_matching_partner_for_valid_token() {
+ let partners = vec![make_test_partner("ssp_x", VALID_API_TOKEN)];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ let mut req = Request::new("GET", "https://edge.example.com/_ts/api/v1/identify");
+ req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}"));
+
+ let result = authenticate_bearer(®istry, &req).expect("should authenticate partner");
+ assert_eq!(result.id, "ssp_x", "should return the matching partner");
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs
new file mode 100644
index 00000000..24c7d65d
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/batch_sync.rs
@@ -0,0 +1,596 @@
+//! Server-to-server batch sync endpoint (`POST /_ts/api/v1/batch-sync`).
+//!
+//! Partners send authenticated batch ID sync requests via Bearer token.
+//! Each mapping associates an `ec_id` (`{64hex}.{6alnum}`)
+//! with the partner's user ID. Mappings are individually validated and
+//! written to the KV identity graph, with per-mapping rejection reasons
+//! reported in the response.
+//!
+//! Mapping timestamps are retained in the request schema for client
+//! compatibility, but the EC identity graph no longer stores per-partner sync
+//! timestamps. Valid mappings therefore use idempotent last-write-wins
+//! semantics: unchanged UIDs are accepted without a write; different UIDs
+//! replace the stored value regardless of timestamp.
+
+use error_stack::{Report, ResultExt};
+use fastly::http::StatusCode;
+use fastly::{Request, Response};
+use serde::{Deserialize, Serialize};
+
+use crate::error::TrustedServerError;
+
+use super::auth::authenticate_bearer;
+use super::generation::{is_valid_ec_id, normalize_ec_id_for_kv};
+use super::kv::{KvIdentityGraph, UpsertResult};
+use super::log_id;
+use super::rate_limiter::RateLimiter;
+use super::registry::PartnerRegistry;
+
+const REASON_INVALID_EC_ID: &str = "invalid_ec_id";
+const REASON_INVALID_PARTNER_UID: &str = "invalid_partner_uid";
+const REASON_INELIGIBLE: &str = "ineligible";
+const REASON_KV_UNAVAILABLE: &str = "kv_unavailable";
+
+/// Maximum number of mappings allowed in a single batch request.
+const MAX_BATCH_SIZE: usize = 1000;
+
+use super::kv_types::MAX_UID_LENGTH;
+
+trait BatchSyncWriter {
+ fn upsert_partner_id_if_exists(
+ &self,
+ ec_id: &str,
+ partner_id: &str,
+ uid: &str,
+ ) -> Result>;
+}
+
+impl BatchSyncWriter for KvIdentityGraph {
+ fn upsert_partner_id_if_exists(
+ &self,
+ ec_id: &str,
+ partner_id: &str,
+ uid: &str,
+ ) -> Result> {
+ KvIdentityGraph::upsert_partner_id_if_exists(self, ec_id, partner_id, uid)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Request / response types
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Deserialize)]
+struct BatchSyncRequest {
+ mappings: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct SyncMapping {
+ ec_id: String,
+ partner_uid: String,
+ // Retained for API compatibility. The EC KV body no longer stores
+ // per-partner timestamps, so this does not order writes.
+ #[allow(dead_code)]
+ timestamp: u64,
+}
+
+#[derive(Debug, Serialize)]
+struct BatchSyncResponse {
+ accepted: usize,
+ rejected: usize,
+ errors: Vec,
+}
+
+#[derive(Debug, Serialize)]
+struct MappingError {
+ index: usize,
+ reason: &'static str,
+}
+
+// ---------------------------------------------------------------------------
+// Handler
+// ---------------------------------------------------------------------------
+
+/// Handles `POST /_ts/api/v1/batch-sync`.
+///
+/// # Errors
+///
+/// Returns [`TrustedServerError`] on serialization or KV store failures.
+pub fn handle_batch_sync(
+ kv: &KvIdentityGraph,
+ registry: &PartnerRegistry,
+ rate_limiter: &dyn RateLimiter,
+ mut req: Request,
+) -> Result> {
+ handle_batch_sync_with_writer(kv, registry, rate_limiter, &mut req)
+}
+
+fn handle_batch_sync_with_writer(
+ writer: &dyn BatchSyncWriter,
+ registry: &PartnerRegistry,
+ rate_limiter: &dyn RateLimiter,
+ req: &mut Request,
+) -> Result> {
+ // 1. Authenticate
+ let Some(partner) = authenticate_bearer(registry, req) else {
+ return Ok(error_response(StatusCode::UNAUTHORIZED, "invalid_token"));
+ };
+
+ // 2. Rate limit (per-partner, per-minute via batch_rate_limit)
+ let rate_key = format!("batch:{}", partner.id);
+ if rate_limiter.exceeded_per_minute(&rate_key, partner.batch_rate_limit)? {
+ return Ok(error_response(
+ StatusCode::TOO_MANY_REQUESTS,
+ "rate_limit_exceeded",
+ ));
+ }
+
+ // 3. Parse body (with size limit to prevent OOM before validation)
+ const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; // 2 MB
+ if content_length_exceeds_limit(req, MAX_BODY_SIZE) {
+ return Ok(error_response(
+ StatusCode::PAYLOAD_TOO_LARGE,
+ "body_too_large",
+ ));
+ }
+
+ let body_bytes = req.take_body_bytes();
+ if body_bytes.len() > MAX_BODY_SIZE {
+ return Ok(error_response(
+ StatusCode::PAYLOAD_TOO_LARGE,
+ "body_too_large",
+ ));
+ }
+ let body: BatchSyncRequest = serde_json::from_slice(&body_bytes).map_err(|e| {
+ Report::new(TrustedServerError::BadRequest {
+ message: format!("Invalid request body: {e}"),
+ })
+ })?;
+
+ if body.mappings.len() > MAX_BATCH_SIZE {
+ return Ok(error_response(StatusCode::BAD_REQUEST, "batch_too_large"));
+ }
+
+ // 4. Process mappings with per-item validation and rejection reasons.
+ let (accepted, errors) = process_mappings(writer, &partner.id, &body.mappings);
+
+ let rejected = errors.len();
+ let status = if rejected > 0 {
+ StatusCode::MULTI_STATUS
+ } else {
+ StatusCode::OK
+ };
+
+ let response_body = BatchSyncResponse {
+ accepted,
+ rejected,
+ errors,
+ };
+
+ json_response(status, &response_body)
+}
+
+fn content_length_exceeds_limit(req: &Request, max_body_size: usize) -> bool {
+ req.get_header_str("content-length")
+ .and_then(|value| value.parse::().ok())
+ .is_some_and(|content_length| content_length > max_body_size)
+}
+
+fn process_mappings(
+ writer: &dyn BatchSyncWriter,
+ partner_id: &str,
+ mappings: &[SyncMapping],
+) -> (usize, Vec) {
+ let mut accepted: usize = 0;
+ let mut errors = Vec::new();
+
+ for (idx, mapping) in mappings.iter().enumerate() {
+ let ec_id = normalize_ec_id_for_kv(&mapping.ec_id);
+ if !is_valid_ec_id(&ec_id) {
+ errors.push(MappingError {
+ index: idx,
+ reason: REASON_INVALID_EC_ID,
+ });
+ continue;
+ }
+
+ if mapping.partner_uid.trim().is_empty() || mapping.partner_uid.len() > MAX_UID_LENGTH {
+ errors.push(MappingError {
+ index: idx,
+ reason: REASON_INVALID_PARTNER_UID,
+ });
+ continue;
+ }
+ match writer.upsert_partner_id_if_exists(&ec_id, partner_id, &mapping.partner_uid) {
+ Ok(UpsertResult::Written | UpsertResult::Unchanged) => {
+ accepted += 1;
+ }
+ Ok(UpsertResult::NotFound | UpsertResult::ConsentWithdrawn) => {
+ errors.push(MappingError {
+ index: idx,
+ reason: REASON_INELIGIBLE,
+ });
+ }
+ Err(err) => {
+ log::warn!(
+ "Batch sync KV write failed for index {idx} (ec_id '{}'): {err:?}",
+ log_id(&mapping.ec_id),
+ );
+ errors.push(MappingError {
+ index: idx,
+ reason: REASON_KV_UNAVAILABLE,
+ });
+ // Abort remaining mappings on infrastructure failure.
+ for remaining_idx in (idx + 1)..mappings.len() {
+ errors.push(MappingError {
+ index: remaining_idx,
+ reason: REASON_KV_UNAVAILABLE,
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ (accepted, errors)
+}
+
+fn json_response(
+ status: StatusCode,
+ body: &T,
+) -> Result> {
+ let body = serde_json::to_string(body).change_context(TrustedServerError::EdgeCookie {
+ message: "Failed to serialize batch sync response".to_owned(),
+ })?;
+
+ Ok(Response::from_status(status)
+ .with_content_type(fastly::mime::APPLICATION_JSON)
+ .with_body(body))
+}
+
+fn error_response(status: StatusCode, reason: &str) -> Response {
+ let body = serde_json::json!({ "error": reason });
+ Response::from_status(status)
+ .with_content_type(fastly::mime::APPLICATION_JSON)
+ .with_body(body.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::VecDeque;
+
+ use crate::error::TrustedServerError;
+ use crate::redacted::Redacted;
+ use crate::settings::EcPartner;
+
+ // EC ID validation tests are in generation.rs (is_valid_ec_id).
+ // Verify the import works here with a basic smoke test.
+ #[test]
+ fn is_valid_ec_id_smoke_test() {
+ let valid = format!("{}.ABC123", "a".repeat(64));
+ assert!(is_valid_ec_id(&valid));
+ assert!(!is_valid_ec_id(&"a".repeat(64)));
+ }
+
+ struct MockRateLimiter {
+ should_exceed: bool,
+ }
+
+ impl RateLimiter for MockRateLimiter {
+ fn exceeded(
+ &self,
+ _key: &str,
+ _hourly_limit: u32,
+ ) -> Result> {
+ Ok(self.should_exceed)
+ }
+
+ fn exceeded_per_minute(
+ &self,
+ _key: &str,
+ _per_minute_limit: u32,
+ ) -> Result> {
+ Ok(self.should_exceed)
+ }
+ }
+
+ struct MockWriter {
+ results: std::cell::RefCell>>>,
+ }
+
+ impl MockWriter {
+ fn new(results: Vec>>) -> Self {
+ Self {
+ results: std::cell::RefCell::new(results.into()),
+ }
+ }
+ }
+
+ impl BatchSyncWriter for MockWriter {
+ fn upsert_partner_id_if_exists(
+ &self,
+ _ec_id: &str,
+ _partner_id: &str,
+ _uid: &str,
+ ) -> Result> {
+ self.results
+ .borrow_mut()
+ .pop_front()
+ .expect("should provide mock result for each mapping")
+ }
+ }
+
+ fn mapping(ec_id: &str, partner_uid: &str, timestamp: u64) -> SyncMapping {
+ SyncMapping {
+ ec_id: ec_id.to_owned(),
+ partner_uid: partner_uid.to_owned(),
+ timestamp,
+ }
+ }
+
+ fn make_test_partner(id: &str, api_token: &str) -> EcPartner {
+ EcPartner {
+ id: id.to_owned(),
+ name: format!("Partner {id}"),
+ source_domain: format!("{id}.example.com"),
+ openrtb_atype: EcPartner::default_openrtb_atype(),
+ bidstream_enabled: true,
+ api_token: Redacted::new(api_token.to_owned()),
+ batch_rate_limit: EcPartner::default_batch_rate_limit(),
+ pull_sync_enabled: false,
+ pull_sync_url: None,
+ pull_sync_allowed_domains: vec![],
+ pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(),
+ pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(),
+ ts_pull_token: None,
+ }
+ }
+
+ fn authorized_batch_request(body: &str) -> Request {
+ let mut req = Request::new("POST", "https://edge.example.com/_ts/api/v1/batch-sync");
+ req.set_header("authorization", "Bearer test-token-32-bytes-minimum-value");
+ req.set_body(body.to_owned());
+ req
+ }
+
+ fn test_registry() -> PartnerRegistry {
+ let partners = vec![make_test_partner(
+ "ssp_x",
+ "test-token-32-bytes-minimum-value",
+ )];
+ PartnerRegistry::from_config(&partners).expect("should build registry")
+ }
+
+ #[test]
+ fn content_length_exceeds_limit_detects_oversized_header() {
+ let mut req = authorized_batch_request("{}");
+ req.set_header("content-length", "2097153");
+
+ assert!(
+ content_length_exceeds_limit(&req, 2 * 1024 * 1024),
+ "should reject oversized content-length before reading body"
+ );
+ }
+
+ #[test]
+ fn content_length_exceeds_limit_ignores_missing_or_malformed_header() {
+ let missing = authorized_batch_request("{}");
+ let mut malformed = authorized_batch_request("{}");
+ malformed.set_header("content-length", "not-a-number");
+
+ assert!(
+ !content_length_exceeds_limit(&missing, 2 * 1024 * 1024),
+ "missing content-length should fall back to post-read size check"
+ );
+ assert!(
+ !content_length_exceeds_limit(&malformed, 2 * 1024 * 1024),
+ "malformed content-length should fall back to post-read size check"
+ );
+ }
+
+ #[test]
+ fn handle_batch_sync_rejects_oversized_content_length_before_body_parse() {
+ let writer = MockWriter::new(vec![]);
+ let registry = test_registry();
+ let limiter = MockRateLimiter {
+ should_exceed: false,
+ };
+ let mut req = authorized_batch_request("not-json");
+ req.set_header("content-length", "2097153");
+
+ let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, &mut req)
+ .expect("should return oversized response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::PAYLOAD_TOO_LARGE,
+ "should reject from content-length before parsing body"
+ );
+ }
+
+ #[test]
+ fn handle_batch_sync_uses_post_read_limit_for_malformed_content_length() {
+ let writer = MockWriter::new(vec![]);
+ let registry = test_registry();
+ let limiter = MockRateLimiter {
+ should_exceed: false,
+ };
+ let oversized_body = "{".repeat((2 * 1024 * 1024) + 1);
+ let mut req = authorized_batch_request(&oversized_body);
+ req.set_header("content-length", "not-a-number");
+
+ let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, &mut req)
+ .expect("should return oversized response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::PAYLOAD_TOO_LARGE,
+ "should reject oversized body even when content-length is malformed"
+ );
+ }
+
+ #[test]
+ fn process_mappings_returns_multistatus_errors_per_mapping() {
+ let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]);
+ let mappings = vec![
+ mapping("x", "u1", 1),
+ mapping(&format!("{}.ABC123", "a".repeat(64)), "", 1),
+ mapping(&format!("{}.ABC123", "a".repeat(64)), "u3", 1),
+ ];
+
+ let (accepted, errors) = process_mappings(&writer, "partner", &mappings);
+
+ assert_eq!(accepted, 1, "should count successful writes as accepted");
+ assert_eq!(errors.len(), 2, "should reject invalid mappings only");
+ assert_eq!(errors[0].index, 0);
+ assert_eq!(errors[0].reason, REASON_INVALID_EC_ID);
+ assert_eq!(errors[1].index, 1);
+ assert_eq!(errors[1].reason, REASON_INVALID_PARTNER_UID);
+ }
+
+ #[test]
+ fn process_mappings_aborts_on_kv_unavailable() {
+ let writer = MockWriter::new(vec![
+ Ok(UpsertResult::Written),
+ Err(Report::new(TrustedServerError::KvStore {
+ store_name: "ec_store".to_owned(),
+ message: "down".to_owned(),
+ })),
+ Ok(UpsertResult::Written),
+ ]);
+
+ let mappings = vec![
+ mapping(&format!("{}.ABC123", "a".repeat(64)), "u1", 1),
+ mapping(&format!("{}.ABC123", "b".repeat(64)), "u2", 1),
+ mapping(&format!("{}.ABC123", "c".repeat(64)), "u3", 1),
+ ];
+
+ let (accepted, errors) = process_mappings(&writer, "partner", &mappings);
+
+ assert_eq!(accepted, 1, "should keep accepted count before failure");
+ assert_eq!(
+ errors.len(),
+ 2,
+ "should mark current and remaining as unavailable"
+ );
+ assert_eq!(errors[0].index, 1);
+ assert_eq!(errors[0].reason, REASON_KV_UNAVAILABLE);
+ assert_eq!(errors[1].index, 2);
+ assert_eq!(errors[1].reason, REASON_KV_UNAVAILABLE);
+ }
+
+ #[test]
+ fn handle_batch_sync_rejects_missing_auth() {
+ let kv = KvIdentityGraph::new("test_store");
+ let registry = PartnerRegistry::empty();
+ let limiter = MockRateLimiter {
+ should_exceed: false,
+ };
+ let req = Request::new("POST", "https://edge.example.com/_ts/api/v1/batch-sync");
+
+ let response =
+ handle_batch_sync(&kv, ®istry, &limiter, req).expect("should return response");
+ assert_eq!(
+ response.get_status(),
+ StatusCode::UNAUTHORIZED,
+ "should return 401 for missing auth"
+ );
+ }
+
+ #[test]
+ fn batch_sync_request_deserializes_correctly() {
+ let json = r#"{"mappings": [{"ec_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ABC123", "partner_uid": "u1", "timestamp": 100}]}"#;
+ let parsed: BatchSyncRequest =
+ serde_json::from_str(json).expect("should deserialize batch sync request");
+ assert_eq!(parsed.mappings.len(), 1);
+ assert_eq!(
+ parsed.mappings[0].ec_id,
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ABC123"
+ );
+ assert_eq!(parsed.mappings[0].partner_uid, "u1");
+ assert_eq!(parsed.mappings[0].timestamp, 100);
+ }
+
+ #[test]
+ fn batch_sync_request_rejects_missing_timestamp() {
+ let json = r#"{"mappings": [{"ec_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ABC123", "partner_uid": "u2"}]}"#;
+ let result = serde_json::from_str::(json);
+ assert!(
+ result.is_err(),
+ "should reject mapping without required timestamp"
+ );
+ }
+
+ #[test]
+ fn batch_sync_response_serializes_correctly() {
+ let response = BatchSyncResponse {
+ accepted: 5,
+ rejected: 1,
+ errors: vec![MappingError {
+ index: 3,
+ reason: REASON_INELIGIBLE,
+ }],
+ };
+
+ let json: serde_json::Value =
+ serde_json::to_value(&response).expect("should serialize batch sync response");
+ assert_eq!(json["accepted"], 5);
+ assert_eq!(json["rejected"], 1);
+ assert_eq!(json["errors"][0]["index"], 3);
+ assert_eq!(json["errors"][0]["reason"], REASON_INELIGIBLE);
+ }
+
+ #[test]
+ fn process_mappings_collapses_missing_and_withdrawn_to_ineligible() {
+ let writer = MockWriter::new(vec![
+ Ok(UpsertResult::NotFound),
+ Ok(UpsertResult::ConsentWithdrawn),
+ ]);
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let mappings = vec![mapping(&ec_id, "uid-1", 100), mapping(&ec_id, "uid-2", 101)];
+
+ let (accepted, errors) = process_mappings(&writer, "partner", &mappings);
+
+ assert_eq!(accepted, 0, "should not accept ineligible mappings");
+ assert_eq!(errors.len(), 2, "should report both errors");
+ assert_eq!(errors[0].index, 0);
+ assert_eq!(errors[0].reason, REASON_INELIGIBLE);
+ assert_eq!(errors[1].index, 1);
+ assert_eq!(errors[1].reason, REASON_INELIGIBLE);
+ }
+
+ #[test]
+ fn process_mappings_counts_unchanged_as_accepted() {
+ let writer = MockWriter::new(vec![Ok(UpsertResult::Unchanged)]);
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let mappings = vec![mapping(&ec_id, "uid-1", 100)];
+
+ let (accepted, errors) = process_mappings(&writer, "partner", &mappings);
+
+ assert_eq!(accepted, 1, "should count unchanged mappings as accepted");
+ assert!(
+ errors.is_empty(),
+ "should report no errors for unchanged mappings"
+ );
+ }
+
+ #[test]
+ fn process_mappings_does_not_order_by_timestamp() {
+ let writer = MockWriter::new(vec![Ok(UpsertResult::Written), Ok(UpsertResult::Written)]);
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let mappings = vec![
+ mapping(&ec_id, "uid-new", 200),
+ mapping(&ec_id, "uid-old", 100),
+ ];
+
+ let (accepted, errors) = process_mappings(&writer, "partner", &mappings);
+
+ assert_eq!(
+ accepted, 2,
+ "timestamps are compatibility fields and should not reject older mappings"
+ );
+ assert!(errors.is_empty(), "should accept valid mappings");
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs
new file mode 100644
index 00000000..ad9f5dd2
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/consent.rs
@@ -0,0 +1,77 @@
+//! 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)
+}
+
+/// Returns `true` when the request carries an explicit EC withdrawal signal.
+///
+/// This is intentionally stricter than [`ec_consent_granted`]. A fail-closed
+/// result such as unknown jurisdiction or missing consent data must not be
+/// treated as an authoritative withdrawal of an already-issued EC.
+#[must_use]
+pub fn ec_consent_withdrawn(consent_context: &ConsentContext) -> bool {
+ crate::consent::has_explicit_ec_withdrawal(consent_context)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::consent::jurisdiction::Jurisdiction;
+
+ #[test]
+ fn ec_consent_granted_allows_non_regulated_requests() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::NonRegulated,
+ ..ConsentContext::default()
+ };
+
+ assert!(
+ ec_consent_granted(&ctx),
+ "non-regulated requests should be allowed"
+ );
+ }
+
+ #[test]
+ fn ec_consent_granted_blocks_unknown_jurisdiction() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::Unknown,
+ ..ConsentContext::default()
+ };
+
+ assert!(
+ !ec_consent_granted(&ctx),
+ "unknown jurisdiction should fail closed"
+ );
+ }
+
+ #[test]
+ fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() {
+ let ctx = ConsentContext {
+ jurisdiction: Jurisdiction::Unknown,
+ ..ConsentContext::default()
+ };
+
+ assert!(
+ !ec_consent_withdrawn(&ctx),
+ "unknown jurisdiction should block creation without revoking existing EC"
+ );
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs
new file mode 100644
index 00000000..46304852
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/cookies.rs
@@ -0,0 +1,349 @@
+//! 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)
+//! - `HttpOnly` prevents client-side JS from reading the cookie via
+//! `document.cookie`, providing XSS defense-in-depth. The identify
+//! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response
+//! body for legitimate JS use cases.
+
+use std::borrow::Cow;
+
+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;
+
+fn is_allowed_ec_id_char(c: char) -> bool {
+ c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
+}
+
+// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a
+// defense-in-depth backstop when setting the Set-Cookie header. This is
+// intentionally broader than the inbound format validator
+// (`generation::is_valid_ec_id`), which enforces the exact
+// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted
+// request values before they enter the system.
+#[must_use]
+pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool {
+ ec_id.chars().all(is_allowed_ec_id_char)
+}
+
+fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> {
+ if ec_id_has_only_allowed_chars(ec_id) {
+ return Cow::Borrowed(ec_id);
+ }
+
+ let safe_id = ec_id
+ .chars()
+ .filter(|c| is_allowed_ec_id_char(*c))
+ .collect::();
+
+ log::warn!(
+ "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \
+ callers should reject invalid request IDs before cookie creation",
+ ec_id.len(),
+ safe_id.len(),
+ );
+
+ Cow::Owned(safe_id)
+}
+
+/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`.
+/// An empty string is always rejected.
+///
+/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace,
+/// double-quote, comma, semicolon, and backslash. Rejecting these characters
+/// prevents header-injection attacks where a crafted value could append
+/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`).
+///
+/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their
+/// byte values exceed `0x7E`.
+#[must_use]
+fn is_safe_cookie_value(value: &str) -> bool {
+ // RFC 6265 §4.1.1 cookie-octet:
+ // 0x21 — '!'
+ // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE)
+ // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma)
+ // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon)
+ // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL)
+ // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded.
+ !value.is_empty()
+ && value
+ .bytes()
+ .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E))
+}
+
+/// 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={}; HttpOnly",
+ 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. The EC ID is
+/// sanitized through a narrow outbound allowlist as a defense-in-depth
+/// backstop against header injection.
+#[must_use]
+pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String {
+ let safe_id = sanitize_ec_id_for_cookie(ec_id);
+
+ format_set_cookie(
+ &settings.publisher.ec_cookie_domain(),
+ safe_id.as_ref(),
+ COOKIE_MAX_AGE,
+ )
+}
+
+/// Sets the EC ID cookie on the given response.
+///
+/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before
+/// interpolation. If the value contains unsafe characters (e.g. semicolons),
+/// the cookie is not set and a warning is logged. This prevents an attacker
+/// from injecting spurious cookie attributes via a controlled ID value.
+///
+/// `cookie_domain` comes from operator configuration and is considered trusted.
+///
+/// # Panics (debug only)
+///
+/// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`]
+/// as a defense-in-depth check against cookie injection.
+pub fn set_ec_cookie(settings: &Settings, response: &mut fastly::Response, ec_id: &str) {
+ if !is_safe_cookie_value(ec_id) {
+ log::warn!(
+ "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value",
+ ec_id.len()
+ );
+ return;
+ }
+
+ debug_assert!(
+ super::generation::is_valid_ec_id(ec_id),
+ "EC ID must be validated before cookie creation: got '{ec_id}'"
+ );
+
+ 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),
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::test_support::tests::create_test_settings;
+ use fastly::http::header;
+
+ /// A valid EC ID for use in cookie tests.
+ const TEST_EC_ID: &str =
+ "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffff0000000011111111.abcXYZ";
+
+ #[test]
+ fn create_ec_cookie_uses_computed_domain() {
+ let settings = create_test_settings();
+ let result = create_ec_cookie(&settings, TEST_EC_ID);
+
+ assert_eq!(
+ result,
+ format!(
+ "{}={}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly",
+ COOKIE_TS_EC, TEST_EC_ID, 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_EC_ID);
+
+ 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_EC_ID),
+ "should match create_ec_cookie output"
+ );
+ }
+
+ #[test]
+ fn create_ec_cookie_sanitizes_disallowed_chars_in_id() {
+ let settings = create_test_settings();
+ let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz");
+ 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, "evilinjectedfoobarbaz",
+ "should strip disallowed characters and preserve safe chars"
+ );
+ }
+
+ #[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() {
+ let settings = create_test_settings();
+ let mut response = fastly::Response::new();
+ set_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com");
+
+ assert!(
+ response.get_header(header::SET_COOKIE).is_none(),
+ "should not set Set-Cookie when value contains a semicolon"
+ );
+ }
+
+ #[test]
+ fn set_ec_cookie_rejects_crlf() {
+ let settings = create_test_settings();
+ let mut response = fastly::Response::new();
+ set_ec_cookie(&settings, &mut response, "evil\r\nX-Injected: header");
+
+ assert!(
+ response.get_header(header::SET_COOKIE).is_none(),
+ "should not set Set-Cookie when value contains CRLF"
+ );
+ }
+
+ #[test]
+ fn set_ec_cookie_rejects_space() {
+ let settings = create_test_settings();
+ let mut response = fastly::Response::new();
+ set_ec_cookie(&settings, &mut response, "bad value");
+
+ assert!(
+ response.get_header(header::SET_COOKIE).is_none(),
+ "should not set Set-Cookie when value contains whitespace"
+ );
+ }
+
+ #[test]
+ fn is_safe_cookie_value_rejects_empty_string() {
+ assert!(!is_safe_cookie_value(""), "should reject empty string");
+ }
+
+ #[test]
+ fn is_safe_cookie_value_accepts_valid_ec_id_characters() {
+ assert!(
+ is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"),
+ "should accept hex digits, dots, and alphanumeric characters"
+ );
+ }
+
+ #[test]
+ fn is_safe_cookie_value_rejects_non_ascii() {
+ assert!(
+ !is_safe_cookie_value("valüe"),
+ "should reject non-ASCII UTF-8 characters"
+ );
+ }
+
+ #[test]
+ fn is_safe_cookie_value_rejects_illegal_characters() {
+ assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon");
+ assert!(!is_safe_cookie_value("val,ue"), "should reject comma");
+ assert!(
+ !is_safe_cookie_value("val\"ue"),
+ "should reject double-quote"
+ );
+ assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash");
+ assert!(!is_safe_cookie_value("val ue"), "should reject space");
+ assert!(
+ !is_safe_cookie_value("val\x00ue"),
+ "should reject null byte"
+ );
+ assert!(
+ !is_safe_cookie_value("val\x7fue"),
+ "should reject DEL character"
+ );
+ }
+
+ #[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"
+ );
+ }
+
+ #[test]
+ fn expire_ec_cookie_matches_security_attributes() {
+ 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_eq!(
+ cookie_str,
+ format!(
+ "{}=; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age=0; HttpOnly",
+ COOKIE_TS_EC, settings.publisher.domain,
+ ),
+ "expiry cookie should retain the same security attributes as the live cookie"
+ );
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs
new file mode 100644
index 00000000..7e540bce
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/device.rs
@@ -0,0 +1,574 @@
+//! Device signal derivation for bot detection and browser classification.
+//!
+//! All functions in this module are pure computations — no KV I/O or Fastly
+//! SDK calls. The Fastly adapter extracts raw strings from the request
+//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`, UA header) and passes
+//! them here for classification.
+//!
+//! # Signals
+//!
+//! - **`is_mobile`** — `0` desktop, `1` mobile, `2` unknown (rare; bots or
+//! hardened clients)
+//! - **`ja4_class`** — JA4 Section 1 only (browser family identifier)
+//! - **`platform_class`** — coarse OS family from UA
+//! - **`h2_fp_hash`** — SHA256 prefix (12 hex chars) of raw H2 SETTINGS
+//! - **`known_browser`** — `true` if `ja4_class` + `h2_fp_hash` match a known
+//! browser pattern; `false` for known bots; `None` for unknown
+
+use sha2::{Digest as _, Sha256};
+
+use super::kv_types::KvDevice;
+
+/// Device signals derived from a single request.
+///
+/// Computed in the Fastly adapter from raw TLS/H2/UA data, then passed to
+/// core for storage and gating decisions. This type lives in core so it
+/// can be used in [`KvDevice`] construction and tested without Fastly.
+#[derive(Debug, Clone, PartialEq)]
+pub struct DeviceSignals {
+ /// `0` = desktop, `1` = mobile, `2` = unknown.
+ pub is_mobile: u8,
+ /// JA4 Section 1 (e.g. `"t13d1516h2"`).
+ pub ja4_class: Option,
+ /// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`,
+ /// `"linux"`.
+ pub platform_class: Option,
+ /// SHA256 prefix (12 hex chars) of raw H2 SETTINGS fingerprint.
+ pub h2_fp_hash: Option,
+ /// `true` = known browser, `false` = known bot, `None` = unknown.
+ pub known_browser: Option,
+}
+
+impl DeviceSignals {
+ /// Derives all device signals from raw request data.
+ ///
+ /// `ua` is the `User-Agent` header value. `ja4` is the full JA4 hash
+ /// from `req.get_tls_ja4()`. `h2_fp` is the raw H2 SETTINGS string
+ /// from `req.get_client_h2_fingerprint()`.
+ #[must_use]
+ pub fn derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> Self {
+ let is_mobile = parse_is_mobile(ua);
+ let ja4_class = ja4.and_then(extract_ja4_section1);
+ let platform_class = parse_platform_class(ua);
+ let h2_fp_hash = h2_fp.map(compute_h2_fp_hash);
+ let known_browser = evaluate_known_browser(ja4_class.as_deref(), h2_fp_hash.as_deref());
+
+ Self {
+ is_mobile,
+ ja4_class,
+ platform_class,
+ h2_fp_hash,
+ known_browser,
+ }
+ }
+
+ /// Returns `true` when the request looks like a real browser.
+ ///
+ /// Checks for the presence of recognizable signals rather than matching
+ /// against a hardcoded fingerprint allowlist. Real browsers always
+ /// produce a valid TLS fingerprint (`ja4_class`) and a recognizable UA
+ /// platform string (`platform_class`). Raw HTTP clients (curl, Python
+ /// requests, Go net/http, headless scrapers) typically lack one or both.
+ ///
+ /// # Threat model
+ ///
+ /// This heuristic is intentionally aimed at filtering obvious
+ /// missing-signal traffic, not at resisting deliberate spoofing. A bot
+ /// that forges plausible JA4 and UA inputs may still pass; deeper
+ /// consistency checks can be added later if product requirements demand
+ /// stronger spoof resistance.
+ ///
+ /// `known_browser` is still computed and stored on [`KvDevice`] for
+ /// analytics but does not gate identity operations.
+ #[must_use]
+ pub fn looks_like_browser(&self) -> bool {
+ self.ja4_class.is_some() && self.platform_class.is_some()
+ }
+
+ /// Converts these signals into a [`KvDevice`] for KV storage.
+ #[must_use]
+ pub fn to_kv_device(&self) -> KvDevice {
+ KvDevice {
+ is_mobile: self.is_mobile,
+ ja4_class: self.ja4_class.clone(),
+ platform_class: self.platform_class.clone(),
+ h2_fp_hash: self.h2_fp_hash.clone(),
+ known_browser: self.known_browser,
+ }
+ }
+}
+
+/// Device is a desktop (confirmed via UA platform token).
+const MOBILE_DESKTOP: u8 = 0;
+/// Device is a mobile (confirmed via UA mobile token).
+const MOBILE_MOBILE: u8 = 1;
+/// Device type is genuinely unknown (typically bots or hardened clients).
+const MOBILE_UNKNOWN: u8 = 2;
+
+/// Derives mobile signal from the User-Agent string.
+///
+/// Returns [`MOBILE_DESKTOP`] for confirmed desktop,
+/// [`MOBILE_MOBILE`] for confirmed mobile,
+/// [`MOBILE_UNKNOWN`] for genuinely unknown (typically bots or hardened clients).
+#[must_use]
+fn parse_is_mobile(ua: &str) -> u8 {
+ // Mobile patterns checked first — more specific.
+ if ua.contains("iPhone") || ua.contains("iPad") || ua.contains("Android") {
+ return MOBILE_MOBILE;
+ }
+ if ua.contains("Macintosh") || ua.contains("Windows") || ua.contains("Linux") {
+ return MOBILE_DESKTOP;
+ }
+ MOBILE_UNKNOWN
+}
+
+/// Parses coarse OS family from the User-Agent string.
+///
+/// Returns `None` when no recognized platform pattern is found.
+#[must_use]
+fn parse_platform_class(ua: &str) -> Option {
+ // Order matters: check mobile-specific patterns before generic ones.
+ if ua.contains("iPhone") || ua.contains("iPad") {
+ return Some("ios".to_owned());
+ }
+ if ua.contains("Android") {
+ return Some("android".to_owned());
+ }
+ if ua.contains("Macintosh") {
+ return Some("mac".to_owned());
+ }
+ if ua.contains("Windows NT") {
+ return Some("windows".to_owned());
+ }
+ if ua.contains("Linux") {
+ return Some("linux".to_owned());
+ }
+ None
+}
+
+/// Extracts Section 1 from a full JA4 fingerprint.
+///
+/// JA4 format: `section1_section2_section3` separated by underscores.
+/// Section 1 identifies browser family (cipher count, extension count,
+/// ALPN) without uniquely fingerprinting a device.
+///
+/// Returns `None` if the input is empty or has no underscore-delimited
+/// section.
+#[must_use]
+fn extract_ja4_section1(full_ja4: &str) -> Option {
+ let section1 = full_ja4.split('_').next()?;
+ if section1.is_empty() {
+ return None;
+ }
+ Some(section1.to_owned())
+}
+
+/// Computes a 12-hex-char prefix of the SHA256 hash of the raw H2
+/// SETTINGS fingerprint string.
+///
+/// The raw string looks like `"1:65536;2:0;4:6291456;6:262144"`.
+#[must_use]
+fn compute_h2_fp_hash(raw_h2_fp: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(raw_h2_fp.as_bytes());
+ let digest = hasher.finalize();
+ hex::encode(&digest[..6])
+}
+
+/// Known browser fingerprint allowlist.
+///
+/// Each entry is `(ja4_class, h2_fp_prefix, known_browser)`.
+/// `h2_fp_prefix` is the raw H2 SETTINGS string (not the hash) — we
+/// compare against the hash computed from it.
+///
+/// Empirically derived from Fastly Compute production responses (2026-04-03).
+const KNOWN_BROWSERS: &[(&str, &str, bool)] = &[
+ // Chrome/Mac v146
+ ("t13d1516h2", "1:65536;2:0;4:6291456;6:262144", true),
+ // Safari/Mac v26 and Safari/iOS v26
+ ("t13d2013h2", "2:0;3:100;4:2097152", true),
+ // Firefox/Mac v149
+ ("t13d1717h2", "1:65536;2:0;4:131072;5:16384", true),
+];
+
+/// Returns H2 fingerprint hashes for the known browser allowlist.
+///
+/// Computed once on first call and cached via `OnceLock`.
+fn known_browser_h2_hashes() -> &'static Vec<(&'static str, String, bool)> {
+ static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new();
+ CACHE.get_or_init(|| {
+ KNOWN_BROWSERS
+ .iter()
+ .map(|(ja4, h2_raw, known)| (*ja4, compute_h2_fp_hash(h2_raw), *known))
+ .collect()
+ })
+}
+
+/// Evaluates whether a request comes from a known browser.
+///
+/// Returns `Some(true)` if `ja4_class` + `h2_fp_hash` match a known
+/// legitimate browser pattern. Returns `Some(false)` for known
+/// bot/scraper patterns. Returns `None` for unrecognized combinations.
+///
+/// Both signals must be present for a match — if either is `None`,
+/// returns `None`.
+#[must_use]
+fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> Option {
+ let ja4 = ja4_class?;
+ let h2_hash = h2_fp_hash?;
+
+ for (known_ja4, known_h2_hash, is_browser) in known_browser_h2_hashes() {
+ if ja4 == *known_ja4 && h2_hash == *known_h2_hash {
+ return Some(*is_browser);
+ }
+ }
+
+ // No match — unknown client.
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Chrome Mac UA
+ const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
+ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
+
+ // Safari iOS UA
+ const SAFARI_IOS_UA: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) \
+ AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1";
+
+ // Safari Mac UA
+ const SAFARI_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
+ AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15";
+
+ // Firefox Mac UA
+ const FIREFOX_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; \
+ rv:149.0) Gecko/20100101 Firefox/149.0";
+
+ // Android Chrome UA
+ const CHROME_ANDROID_UA: &str = "Mozilla/5.0 (Linux; Android 14; Pixel 8) \
+ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36";
+
+ // Windows Chrome UA
+ const CHROME_WINDOWS_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
+ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
+
+ // Bot/empty UA
+ const BOT_UA: &str = "Googlebot/2.1 (+http://www.google.com/bot.html)";
+
+ #[test]
+ fn is_mobile_desktop_browsers() {
+ assert_eq!(parse_is_mobile(CHROME_MAC_UA), 0, "Chrome/Mac = desktop");
+ assert_eq!(parse_is_mobile(SAFARI_MAC_UA), 0, "Safari/Mac = desktop");
+ assert_eq!(parse_is_mobile(FIREFOX_MAC_UA), 0, "Firefox/Mac = desktop");
+ assert_eq!(
+ parse_is_mobile(CHROME_WINDOWS_UA),
+ 0,
+ "Chrome/Windows = desktop"
+ );
+ }
+
+ #[test]
+ fn is_mobile_mobile_browsers() {
+ assert_eq!(parse_is_mobile(SAFARI_IOS_UA), 1, "Safari/iOS = mobile");
+ assert_eq!(
+ parse_is_mobile(CHROME_ANDROID_UA),
+ 1,
+ "Chrome/Android = mobile"
+ );
+ }
+
+ #[test]
+ fn is_mobile_unknown() {
+ assert_eq!(parse_is_mobile(BOT_UA), 2, "Googlebot = unknown");
+ assert_eq!(parse_is_mobile(""), 2, "empty UA = unknown");
+ }
+
+ #[test]
+ fn platform_class_desktop() {
+ assert_eq!(parse_platform_class(CHROME_MAC_UA).as_deref(), Some("mac"));
+ assert_eq!(
+ parse_platform_class(CHROME_WINDOWS_UA).as_deref(),
+ Some("windows")
+ );
+ assert_eq!(parse_platform_class(FIREFOX_MAC_UA).as_deref(), Some("mac"));
+ }
+
+ #[test]
+ fn platform_class_mobile() {
+ assert_eq!(parse_platform_class(SAFARI_IOS_UA).as_deref(), Some("ios"));
+ assert_eq!(
+ parse_platform_class(CHROME_ANDROID_UA).as_deref(),
+ Some("android")
+ );
+ }
+
+ #[test]
+ fn platform_class_linux() {
+ let linux_ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36";
+ assert_eq!(parse_platform_class(linux_ua).as_deref(), Some("linux"));
+ }
+
+ #[test]
+ fn platform_class_unknown() {
+ assert_eq!(parse_platform_class(BOT_UA), None);
+ assert_eq!(parse_platform_class(""), None);
+ }
+
+ #[test]
+ fn ja4_section1_extraction() {
+ assert_eq!(
+ extract_ja4_section1("t13d1516h2_8daaf6152771_e5627efa2ab1").as_deref(),
+ Some("t13d1516h2"),
+ "should extract section 1 from full JA4"
+ );
+ }
+
+ #[test]
+ fn ja4_section1_no_underscore() {
+ // Some implementations may return just section 1
+ assert_eq!(
+ extract_ja4_section1("t13d1516h2").as_deref(),
+ Some("t13d1516h2"),
+ "should handle JA4 with no underscore"
+ );
+ }
+
+ #[test]
+ fn ja4_section1_empty() {
+ assert_eq!(extract_ja4_section1(""), None);
+ }
+
+ #[test]
+ fn h2_fp_hash_deterministic() {
+ let hash1 = compute_h2_fp_hash("1:65536;2:0;4:6291456;6:262144");
+ let hash2 = compute_h2_fp_hash("1:65536;2:0;4:6291456;6:262144");
+ assert_eq!(hash1, hash2, "should be deterministic");
+ assert_eq!(hash1.len(), 12, "should be 12 hex chars");
+ }
+
+ #[test]
+ fn h2_fp_hash_different_inputs() {
+ let chrome = compute_h2_fp_hash("1:65536;2:0;4:6291456;6:262144");
+ let safari = compute_h2_fp_hash("2:0;3:100;4:2097152");
+ assert_ne!(
+ chrome, safari,
+ "different inputs should produce different hashes"
+ );
+ }
+
+ #[test]
+ fn known_browser_chrome_match() {
+ let ja4 = "t13d1516h2";
+ let h2_hash = compute_h2_fp_hash("1:65536;2:0;4:6291456;6:262144");
+ assert_eq!(
+ evaluate_known_browser(Some(ja4), Some(&h2_hash)),
+ Some(true),
+ "Chrome fingerprint should be recognized"
+ );
+ }
+
+ #[test]
+ fn known_browser_safari_match() {
+ let ja4 = "t13d2013h2";
+ let h2_hash = compute_h2_fp_hash("2:0;3:100;4:2097152");
+ assert_eq!(
+ evaluate_known_browser(Some(ja4), Some(&h2_hash)),
+ Some(true),
+ "Safari fingerprint should be recognized"
+ );
+ }
+
+ #[test]
+ fn known_browser_firefox_match() {
+ let ja4 = "t13d1717h2";
+ let h2_hash = compute_h2_fp_hash("1:65536;2:0;4:131072;5:16384");
+ assert_eq!(
+ evaluate_known_browser(Some(ja4), Some(&h2_hash)),
+ Some(true),
+ "Firefox fingerprint should be recognized"
+ );
+ }
+
+ #[test]
+ fn known_browser_unknown_combination() {
+ let ja4 = "t13d9999h2";
+ let h2_hash = compute_h2_fp_hash("1:1;2:2;3:3");
+ assert_eq!(
+ evaluate_known_browser(Some(ja4), Some(&h2_hash)),
+ None,
+ "unknown combination should return None"
+ );
+ }
+
+ #[test]
+ fn known_browser_mismatched_ja4_h2() {
+ // Chrome JA4 but Safari H2
+ let ja4 = "t13d1516h2";
+ let h2_hash = compute_h2_fp_hash("2:0;3:100;4:2097152");
+ assert_eq!(
+ evaluate_known_browser(Some(ja4), Some(&h2_hash)),
+ None,
+ "mismatched JA4/H2 should return None"
+ );
+ }
+
+ #[test]
+ fn known_browser_missing_signals() {
+ assert_eq!(
+ evaluate_known_browser(None, Some("abcdef123456")),
+ None,
+ "missing JA4 should return None"
+ );
+ assert_eq!(
+ evaluate_known_browser(Some("t13d1516h2"), None),
+ None,
+ "missing H2 hash should return None"
+ );
+ assert_eq!(
+ evaluate_known_browser(None, None),
+ None,
+ "both missing should return None"
+ );
+ }
+
+ #[test]
+ fn derive_chrome_mac() {
+ let signals = DeviceSignals::derive(
+ CHROME_MAC_UA,
+ Some("t13d1516h2_8daaf6152771_e5627efa2ab1"),
+ Some("1:65536;2:0;4:6291456;6:262144"),
+ );
+
+ assert_eq!(signals.is_mobile, 0);
+ assert_eq!(signals.ja4_class.as_deref(), Some("t13d1516h2"));
+ assert_eq!(signals.platform_class.as_deref(), Some("mac"));
+ assert!(signals.h2_fp_hash.is_some());
+ assert_eq!(signals.known_browser, Some(true));
+ }
+
+ #[test]
+ fn derive_safari_ios() {
+ let signals = DeviceSignals::derive(
+ SAFARI_IOS_UA,
+ Some("t13d2013h2_abcdef123456_fedcba654321"),
+ Some("2:0;3:100;4:2097152"),
+ );
+
+ assert_eq!(signals.is_mobile, 1);
+ assert_eq!(signals.ja4_class.as_deref(), Some("t13d2013h2"));
+ assert_eq!(signals.platform_class.as_deref(), Some("ios"));
+ assert_eq!(signals.known_browser, Some(true));
+ }
+
+ #[test]
+ fn derive_bot() {
+ let signals = DeviceSignals::derive(BOT_UA, None, None);
+
+ assert_eq!(signals.is_mobile, 2);
+ assert!(signals.ja4_class.is_none());
+ assert!(signals.platform_class.is_none());
+ assert!(signals.h2_fp_hash.is_none());
+ assert_eq!(signals.known_browser, None);
+ }
+
+ #[test]
+ fn to_kv_device_conversion() {
+ let signals = DeviceSignals::derive(
+ CHROME_MAC_UA,
+ Some("t13d1516h2_8daaf6152771_e5627efa2ab1"),
+ Some("1:65536;2:0;4:6291456;6:262144"),
+ );
+ let device = signals.to_kv_device();
+
+ assert_eq!(device.is_mobile, signals.is_mobile);
+ assert_eq!(device.ja4_class, signals.ja4_class);
+ assert_eq!(device.platform_class, signals.platform_class);
+ assert_eq!(device.h2_fp_hash, signals.h2_fp_hash);
+ assert_eq!(device.known_browser, signals.known_browser);
+ }
+
+ #[test]
+ fn android_is_linux_but_platform_class_android() {
+ // Android UA contains "Linux" — platform_class should be "android"
+ // not "linux" because we check Android before Linux.
+ assert_eq!(
+ parse_platform_class(CHROME_ANDROID_UA).as_deref(),
+ Some("android"),
+ "Android should take precedence over Linux"
+ );
+ // But is_mobile should be 1 since it contains "Android".
+ assert_eq!(parse_is_mobile(CHROME_ANDROID_UA), 1);
+ }
+
+ #[test]
+ fn ipad_is_mobile() {
+ let ipad_ua = "Mozilla/5.0 (iPad; CPU OS 26_0 like Mac OS X) \
+ AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/604.1";
+ assert_eq!(parse_is_mobile(ipad_ua), 1, "iPad should be mobile");
+ assert_eq!(
+ parse_platform_class(ipad_ua).as_deref(),
+ Some("ios"),
+ "iPad should be ios"
+ );
+ }
+
+ #[test]
+ fn looks_like_browser_with_both_signals() {
+ let signals = DeviceSignals::derive(
+ CHROME_MAC_UA,
+ Some("t13d1516h2_8daaf6152771_e5627efa2ab1"),
+ Some("1:65536;2:0;4:6291456;6:262144"),
+ );
+ assert!(
+ signals.looks_like_browser(),
+ "Chrome/Mac should look like a browser"
+ );
+ }
+
+ #[test]
+ fn looks_like_browser_unknown_fingerprint_still_passes() {
+ // Chrome/Windows with unknown JA4/H2 — still has ja4_class and platform_class
+ let signals = DeviceSignals::derive(
+ CHROME_WINDOWS_UA,
+ Some("t13d9999h2_unknown_unknown"),
+ Some("99:99;88:88"),
+ );
+ assert!(
+ signals.looks_like_browser(),
+ "unknown fingerprint with valid JA4 + platform should pass"
+ );
+ assert_eq!(signals.known_browser, None, "should not match allowlist");
+ }
+
+ #[test]
+ fn looks_like_browser_rejects_bot() {
+ let signals = DeviceSignals::derive(BOT_UA, None, None);
+ assert!(
+ !signals.looks_like_browser(),
+ "bot with no JA4 and no platform should be rejected"
+ );
+ }
+
+ #[test]
+ fn looks_like_browser_rejects_missing_ja4() {
+ // Real UA but no TLS fingerprint (e.g. HTTP/1.1 or missing SDK support)
+ let signals = DeviceSignals::derive(CHROME_MAC_UA, None, Some("1:65536"));
+ assert!(
+ !signals.looks_like_browser(),
+ "missing JA4 should be rejected even with valid UA"
+ );
+ }
+
+ #[test]
+ fn looks_like_browser_rejects_missing_platform() {
+ // Has JA4 but unrecognizable UA
+ let signals = DeviceSignals::derive(BOT_UA, Some("t13d1516h2_abc_def"), None);
+ assert!(
+ !signals.looks_like_browser(),
+ "unrecognizable UA should be rejected even with JA4"
+ );
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs
new file mode 100644
index 00000000..f0debad6
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/eids.rs
@@ -0,0 +1,273 @@
+//! Shared EID resolution and formatting helpers.
+//!
+//! Used by both `/_ts/api/v1/identify` and `/auction` to resolve partner IDs from KV
+//! entries, convert them to `OpenRTB` EID structures, and build base64-encoded
+//! response headers.
+
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
+use error_stack::{Report, ResultExt};
+
+use crate::error::TrustedServerError;
+use crate::openrtb::{Eid, Uid};
+
+use super::kv_types::KvEntry;
+use super::registry::PartnerRegistry;
+
+/// Maximum size (in bytes) for the base64-encoded `x-ts-eids` header value.
+pub const MAX_EIDS_HEADER_BYTES: usize = 4096;
+
+/// A partner ID resolved from a KV entry against the partner registry.
+///
+/// Only includes partners with `bidstream_enabled = true` and a non-empty UID.
+pub struct ResolvedPartnerId {
+ /// Partner namespace key (e.g. `"liveramp"`).
+ pub partner_id: String,
+ /// The synced user ID value.
+ pub uid: String,
+ /// The partner's identity source domain (e.g. `"liveramp.com"`).
+ pub source_domain: String,
+ /// `OpenRTB` agent type for this partner's identifiers.
+ pub openrtb_atype: u8,
+}
+
+/// Resolves partner IDs from a KV entry against the partner registry.
+///
+/// Filters to partners with `bidstream_enabled = true` and non-empty UIDs,
+/// sorted deterministically by partner ID.
+#[must_use]
+pub fn resolve_partner_ids(registry: &PartnerRegistry, entry: &KvEntry) -> Vec {
+ let mut resolved = Vec::new();
+
+ for (partner_id, partner_uid) in &entry.ids {
+ if partner_uid.uid.is_empty() {
+ continue;
+ }
+
+ let Some(partner) = registry.get(partner_id) else {
+ continue;
+ };
+ if !partner.bidstream_enabled {
+ continue;
+ }
+
+ resolved.push(ResolvedPartnerId {
+ partner_id: partner_id.clone(),
+ uid: partner_uid.uid.clone(),
+ source_domain: partner.source_domain.clone(),
+ openrtb_atype: partner.openrtb_atype,
+ });
+ }
+
+ resolved.sort_by(|a, b| a.partner_id.cmp(&b.partner_id));
+ resolved
+}
+
+/// Converts resolved partner IDs to `OpenRTB` `Eid` entries.
+#[must_use]
+pub fn to_eids(resolved: &[ResolvedPartnerId]) -> Vec {
+ resolved
+ .iter()
+ .map(|item| Eid {
+ source: item.source_domain.clone(),
+ uids: vec![Uid {
+ id: item.uid.clone(),
+ atype: Some(item.openrtb_atype),
+ ext: None,
+ }],
+ })
+ .collect()
+}
+
+/// Builds a base64-encoded EID header value, truncating if needed.
+///
+/// Returns `(encoded_value, was_truncated)`. If the full set of EIDs exceeds
+/// [`MAX_EIDS_HEADER_BYTES`] after base64 encoding, partners are removed
+/// from the end of the deterministic partner ordering until it fits.
+///
+/// # Errors
+///
+/// Returns an error if JSON serialization fails.
+pub fn build_eids_header(
+ resolved: &[ResolvedPartnerId],
+) -> Result<(String, bool), Report> {
+ let eids = to_eids(resolved);
+ encode_eids_header(&eids)
+}
+
+/// Encodes a pre-built EID slice into a base64 header value with truncation.
+///
+/// Like [`build_eids_header`] but operates on already-constructed `Eid` values
+/// (e.g., from `UserInfo.eids` in the auction response path).
+///
+/// Returns `(encoded_value, was_truncated)`.
+///
+/// # Errors
+///
+/// Returns an error if JSON serialization fails.
+pub fn encode_eids_header(eids: &[Eid]) -> Result<(String, bool), Report> {
+ let try_encode = |size: usize| -> Result> {
+ let json = serde_json::to_vec(&eids[..size]).change_context(
+ TrustedServerError::Configuration {
+ message: "Failed to serialize eids header payload".to_owned(),
+ },
+ )?;
+ Ok(BASE64.encode(json))
+ };
+
+ // Fast path: try the full slice first (common case — no truncation).
+ let encoded = try_encode(eids.len())?;
+ if encoded.len() <= MAX_EIDS_HEADER_BYTES {
+ return Ok((encoded, false));
+ }
+
+ // Binary search for the largest count that fits within the limit.
+ // Invariant: lo always fits, hi never fits.
+ let mut lo: usize = 0;
+ let mut hi: usize = eids.len();
+
+ while lo + 1 < hi {
+ let mid = lo + (hi - lo) / 2;
+ let encoded = try_encode(mid)?;
+ if encoded.len() <= MAX_EIDS_HEADER_BYTES {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+
+ // `lo` is the largest size that fits. Re-encode it for the final value.
+ if lo == 0 && !eids.is_empty() {
+ log::warn!(
+ "encode_eids_header: no EIDs fit within {MAX_EIDS_HEADER_BYTES}B; emitting empty truncated header"
+ );
+ }
+ let encoded = try_encode(lo)?;
+ Ok((encoded, true))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::redacted::Redacted;
+ use crate::settings::EcPartner;
+
+ fn make_test_partner(id: &str, source_domain: &str) -> EcPartner {
+ EcPartner {
+ id: id.to_owned(),
+ name: format!("Partner {id}"),
+ source_domain: source_domain.to_owned(),
+ openrtb_atype: EcPartner::default_openrtb_atype(),
+ bidstream_enabled: true,
+ api_token: Redacted::new(format!("token-{id}-32-bytes-minimum-value")),
+ batch_rate_limit: EcPartner::default_batch_rate_limit(),
+ pull_sync_enabled: false,
+ pull_sync_url: None,
+ pull_sync_allowed_domains: vec![],
+ pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(),
+ pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(),
+ ts_pull_token: None,
+ }
+ }
+
+ #[test]
+ fn resolve_partner_ids_sorts_by_partner_id() {
+ let partners = vec![
+ make_test_partner("zeta", "zeta.example.com"),
+ make_test_partner("alpha", "alpha.example.com"),
+ ];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+
+ let mut entry = KvEntry::tombstone(1000);
+ entry.consent.ok = true;
+ entry.ids.insert(
+ "zeta".to_owned(),
+ super::super::kv_types::KvPartnerId {
+ uid: "uid-z".to_owned(),
+ },
+ );
+ entry.ids.insert(
+ "alpha".to_owned(),
+ super::super::kv_types::KvPartnerId {
+ uid: "uid-a".to_owned(),
+ },
+ );
+
+ let resolved = resolve_partner_ids(®istry, &entry);
+ let partner_ids: Vec<&str> = resolved
+ .iter()
+ .map(|item| item.partner_id.as_str())
+ .collect();
+
+ assert_eq!(
+ partner_ids,
+ vec!["alpha", "zeta"],
+ "should sort deterministically by partner ID"
+ );
+ }
+
+ #[test]
+ fn to_eids_maps_resolved_ids_correctly() {
+ let resolved = vec![
+ ResolvedPartnerId {
+ partner_id: "liveramp".to_owned(),
+ uid: "LR_xyz".to_owned(),
+ source_domain: "liveramp.com".to_owned(),
+ openrtb_atype: 3,
+ },
+ ResolvedPartnerId {
+ partner_id: "id5".to_owned(),
+ uid: "ID5_abc".to_owned(),
+ source_domain: "id5-sync.com".to_owned(),
+ openrtb_atype: 1,
+ },
+ ];
+
+ let eids = to_eids(&resolved);
+
+ assert_eq!(eids.len(), 2, "should produce one EID per resolved partner");
+ assert_eq!(eids[0].source, "liveramp.com");
+ assert_eq!(eids[0].uids[0].id, "LR_xyz");
+ assert_eq!(eids[0].uids[0].atype, Some(3));
+ assert_eq!(eids[1].source, "id5-sync.com");
+ assert_eq!(eids[1].uids[0].id, "ID5_abc");
+ assert_eq!(eids[1].uids[0].atype, Some(1));
+ }
+
+ #[test]
+ fn build_eids_header_truncates_when_too_large() {
+ let mut resolved = Vec::new();
+ for idx in 0..64 {
+ resolved.push(ResolvedPartnerId {
+ partner_id: format!("partner_{idx}"),
+ uid: format!("uid_{}", "x".repeat(100)),
+ source_domain: format!("partner-{idx}.example.com"),
+ openrtb_atype: 3,
+ });
+ }
+
+ let (encoded, truncated) =
+ build_eids_header(&resolved).expect("should build truncated header");
+
+ assert!(truncated, "should report truncation for large payload");
+ assert!(
+ encoded.len() <= MAX_EIDS_HEADER_BYTES,
+ "should cap encoded header bytes"
+ );
+ }
+
+ #[test]
+ fn build_eids_header_fits_without_truncation() {
+ let resolved = vec![ResolvedPartnerId {
+ partner_id: "ssp".to_owned(),
+ uid: "u1".to_owned(),
+ source_domain: "ssp.com".to_owned(),
+ openrtb_atype: 3,
+ }];
+
+ let (encoded, truncated) =
+ build_eids_header(&resolved).expect("should build header without truncation");
+
+ assert!(!truncated, "should not truncate small payload");
+ assert!(!encoded.is_empty(), "should produce non-empty value");
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs
new file mode 100644
index 00000000..9b5c62dc
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/finalize.rs
@@ -0,0 +1,603 @@
+//! EC response finalization.
+//!
+//! Centralizes post-routing EC behavior so all handlers get consistent cookie
+//! and KV semantics.
+
+use std::collections::HashSet;
+
+use fastly::Response;
+
+use super::consent::{ec_consent_granted, ec_consent_withdrawn};
+use crate::settings::Settings;
+
+use super::cookies::{expire_ec_cookie, set_ec_cookie};
+use super::generation::is_valid_ec_id;
+use super::kv::KvIdentityGraph;
+use super::log_id;
+use super::prebid_eids::{ingest_prebid_eids, ingest_sharedid_cookie};
+use super::registry::PartnerRegistry;
+use super::EcContext;
+
+/// TS-managed response headers tied to EC identity output.
+const EC_RESPONSE_HEADERS: &[&str] = &[
+ "x-ts-ec",
+ "x-ts-eids",
+ "x-ts-ec-consent",
+ "x-ts-eids-truncated",
+];
+
+/// Finalizes EC response behavior for all routes.
+///
+/// Applies withdrawal handling, last-seen updates, cookie reconciliation,
+/// Prebid EID ingestion, and cookie writes for new EC generation.
+///
+/// On consent withdrawal, the browser response clears the EC cookie
+/// immediately and the EC identity-graph KV tombstone is the authoritative
+/// revocation marker. There is no separate consent KV store to clean up.
+///
+/// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted
+/// from the request *before* routing consumes it.
+pub fn ec_finalize_response(
+ settings: &Settings,
+ ec_context: &EcContext,
+ kv: Option<&KvIdentityGraph>,
+ registry: &PartnerRegistry,
+ eids_cookie: Option<&str>,
+ sharedid_cookie: Option<&str>,
+ response: &mut Response,
+) {
+ let consent_allows_ec = ec_consent_granted(ec_context.consent());
+ let consent_withdrawn = ec_consent_withdrawn(ec_context.consent());
+
+ if !consent_allows_ec {
+ // Always strip EC-specific response headers when consent is not
+ // currently usable for this request. This covers both explicit
+ // revocation and fail-closed cases such as missing geo or undecodable
+ // 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 && ec_context.cookie_was_present() {
+ expire_ec_cookie(settings, response);
+
+ // Compute once for the authoritative identity-graph tombstones.
+ let ids_to_withdraw = withdrawal_ec_ids(ec_context);
+
+ // The identity-graph tombstone is the authoritative withdrawal marker
+ // for subsequent EC behavior.
+ if let Some(graph) = kv {
+ apply_withdrawal_tombstones(&ids_to_withdraw, |ec_id| {
+ if let Err(err) = graph.write_withdrawal_tombstone(ec_id) {
+ log::error!(
+ "Failed to write withdrawal tombstone for EC ID '{}': {err:?}",
+ log_id(ec_id),
+ );
+ }
+ });
+ }
+ }
+
+ return;
+ }
+
+ // Returning user: consent is granted and EC came from request.
+ if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec {
+ if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) {
+ // Ingest Prebid EIDs from cookie if present.
+ if let Some(cookie) = eids_cookie {
+ ingest_prebid_eids(cookie, ec_id, graph, registry);
+ }
+ if let Some(cookie) = sharedid_cookie {
+ ingest_sharedid_cookie(cookie, ec_id, graph, registry);
+ }
+ }
+
+ // Ordinary returning-user page views no longer refresh the browser
+ // cookie, emit the EC header, or update KV TTL.
+ return;
+ }
+
+ // Newly generated EC in this request. Do not emit a generated EC when
+ // there is no KV graph: that would mint a browser cookie with no backing
+ // identity-graph row, producing a phantom ID on later requests.
+ if ec_context.ec_generated() {
+ let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) else {
+ log::info!("Skipping generated EC response write because KV graph is unavailable");
+ return;
+ };
+
+ if let Some(cookie) = eids_cookie {
+ ingest_prebid_eids(cookie, ec_id, graph, registry);
+ }
+ if let Some(cookie) = sharedid_cookie {
+ ingest_sharedid_cookie(cookie, ec_id, graph, registry);
+ }
+ set_ec_cookie_on_response(settings, ec_context, response);
+ }
+}
+
+/// Sets the EC cookie on response when an EC ID is available.
+pub fn set_ec_cookie_on_response(
+ settings: &Settings,
+ ec_context: &EcContext,
+ response: &mut Response,
+) {
+ if let Some(ec_id) = ec_context.ec_value() {
+ set_ec_cookie(settings, response, ec_id);
+ }
+}
+
+/// Removes EC-specific response headers.
+///
+/// In addition to the fixed [`EC_RESPONSE_HEADERS`], this also strips dynamic
+/// `X-ts-` headers for registered partners. Other `x-ts-*` headers
+/// are intentionally preserved because they may be set by non-EC middleware.
+fn clear_ec_headers_on_response(response: &mut Response, registry: Option<&PartnerRegistry>) {
+ for header in EC_RESPONSE_HEADERS {
+ response.remove_header(*header);
+ }
+
+ if let Some(registry) = registry {
+ for partner in registry.all() {
+ response.remove_header(partner_response_header(&partner.id).as_str());
+ }
+ }
+}
+
+fn partner_response_header(partner_id: &str) -> String {
+ format!("x-ts-{partner_id}")
+}
+
+/// Clears EC cookie and removes EC-specific response headers.
+///
+/// Used when the request carries an explicit withdrawal signal.
+pub fn clear_ec_on_response(settings: &Settings, response: &mut Response) {
+ expire_ec_cookie(settings, response);
+ clear_ec_headers_on_response(response, None);
+}
+
+fn withdrawal_ec_ids(ec_context: &EcContext) -> HashSet {
+ let mut hashes = HashSet::new();
+
+ if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() {
+ if is_valid_ec_id(cookie_ec_id) {
+ hashes.insert(cookie_ec_id.to_owned());
+ }
+ }
+
+ if let Some(active_ec_id) = ec_context.ec_value() {
+ if is_valid_ec_id(active_ec_id) {
+ hashes.insert(active_ec_id.to_owned());
+ }
+ }
+
+ hashes
+}
+
+fn apply_withdrawal_tombstones(ec_ids: &HashSet, mut write_tombstone: F)
+where
+ F: FnMut(&str),
+{
+ for ec_id in ec_ids {
+ write_tombstone(ec_id);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::consent::jurisdiction::Jurisdiction;
+ use crate::consent::types::{ConsentContext, ConsentSource};
+ use crate::redacted::Redacted;
+ use crate::settings::EcPartner;
+ use crate::test_support::tests::create_test_settings;
+
+ fn make_context(
+ ec_value: Option<&str>,
+ cookie_ec_value: Option<&str>,
+ ec_was_present: bool,
+ ec_generated: bool,
+ jurisdiction: Jurisdiction,
+ ) -> EcContext {
+ let consent = ConsentContext {
+ jurisdiction,
+ source: ConsentSource::Cookie,
+ ..Default::default()
+ };
+
+ make_context_with_consent(
+ ec_value,
+ cookie_ec_value,
+ ec_was_present,
+ ec_generated,
+ consent,
+ )
+ }
+
+ fn make_context_with_consent(
+ ec_value: Option<&str>,
+ cookie_ec_value: Option<&str>,
+ ec_was_present: bool,
+ ec_generated: bool,
+ consent: ConsentContext,
+ ) -> EcContext {
+ EcContext::new_for_test_with_cookie(
+ ec_value.map(str::to_owned),
+ cookie_ec_value.map(str::to_owned),
+ ec_was_present,
+ ec_generated,
+ consent,
+ )
+ }
+
+ fn sample_ec_id(suffix: &str) -> String {
+ format!("{}.{suffix}", "a".repeat(64))
+ }
+
+ fn make_partner(id: &str) -> EcPartner {
+ EcPartner {
+ id: id.to_owned(),
+ name: format!("Partner {id}"),
+ source_domain: format!("{id}.example.com"),
+ openrtb_atype: EcPartner::default_openrtb_atype(),
+ bidstream_enabled: true,
+ api_token: Redacted::new(format!("token-{id}-32-bytes-minimum-value")),
+ batch_rate_limit: EcPartner::default_batch_rate_limit(),
+ pull_sync_enabled: false,
+ pull_sync_url: None,
+ pull_sync_allowed_domains: vec![],
+ pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(),
+ pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(),
+ ts_pull_token: None,
+ }
+ }
+
+ #[test]
+ fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() {
+ let cookie_ec = sample_ec_id("cook1e");
+ let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown);
+
+ let ids = withdrawal_ec_ids(&ec_context);
+
+ assert_eq!(ids.len(), 1, "should include exactly one EC ID");
+ assert!(
+ ids.contains(&cookie_ec),
+ "should include the cookie EC value"
+ );
+ }
+
+ #[test]
+ fn withdrawal_ec_ids_deduplicates_matching_cookie_and_active_ec() {
+ let ec_id = sample_ec_id("same01");
+ let ec_context = make_context(
+ Some(&ec_id),
+ Some(&ec_id),
+ true,
+ false,
+ Jurisdiction::Unknown,
+ );
+
+ let ids = withdrawal_ec_ids(&ec_context);
+
+ assert_eq!(ids.len(), 1, "should deduplicate identical EC IDs");
+ assert!(ids.contains(&ec_id), "should retain the shared EC ID");
+ }
+
+ #[test]
+ fn withdrawal_ec_ids_includes_both_cookie_and_active_when_different() {
+ let active_ec = sample_ec_id("activ1");
+ let cookie_ec = sample_ec_id("cook1e");
+ let ec_context = make_context(
+ Some(&active_ec),
+ Some(&cookie_ec),
+ true,
+ false,
+ Jurisdiction::Unknown,
+ );
+
+ let ids = withdrawal_ec_ids(&ec_context);
+
+ assert_eq!(ids.len(), 2, "should include both distinct EC IDs");
+ assert!(ids.contains(&active_ec), "should include active EC ID");
+ assert!(ids.contains(&cookie_ec), "should include cookie EC ID");
+ }
+
+ #[test]
+ fn withdrawal_ec_ids_filters_invalid_values() {
+ let valid_ec = sample_ec_id("valid1");
+ let ec_context = make_context(
+ Some(&valid_ec),
+ Some("not-an-ec-id"),
+ true,
+ false,
+ Jurisdiction::Unknown,
+ );
+
+ let ids = withdrawal_ec_ids(&ec_context);
+
+ assert_eq!(ids.len(), 1, "should ignore malformed EC values");
+ assert!(ids.contains(&valid_ec), "should keep the valid EC ID");
+ }
+
+ #[test]
+ fn apply_withdrawal_tombstones_invokes_writer_for_each_ec_id() {
+ let first = sample_ec_id("first1");
+ let second = sample_ec_id("second");
+ let mut ids = HashSet::new();
+ ids.insert(first.clone());
+ ids.insert(second.clone());
+
+ let mut written = Vec::new();
+ apply_withdrawal_tombstones(&ids, |ec_id| written.push(ec_id.to_owned()));
+ written.sort();
+
+ let mut expected = vec![first, second];
+ expected.sort();
+ assert_eq!(written, expected, "should write a tombstone for each EC ID");
+ }
+
+ #[test]
+ fn clear_ec_on_response_removes_headers_and_expires_cookie() {
+ let settings = create_test_settings();
+ let mut response = Response::new();
+ response.set_header("x-ts-ec", "abc");
+ response.set_header("x-ts-eids", "[]");
+ response.set_header("x-ts-unrelated", "keep-me");
+
+ clear_ec_on_response(&settings, &mut response);
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "should remove x-ts-ec"
+ );
+ assert!(
+ response.get_header("x-ts-eids").is_none(),
+ "should remove x-ts-eids"
+ );
+ assert_eq!(
+ response.get_header_str("x-ts-unrelated"),
+ Some("keep-me"),
+ "should preserve unrelated x-ts headers without a partner registry"
+ );
+
+ let set_cookie = response
+ .get_header("set-cookie")
+ .expect("should append Set-Cookie for expiry")
+ .to_str()
+ .expect("should render set-cookie as utf-8");
+
+ assert!(
+ set_cookie.contains("Max-Age=0"),
+ "should expire the EC cookie"
+ );
+ }
+
+ #[test]
+ fn finalize_withdrawal_clears_cookie_and_headers() {
+ let settings = create_test_settings();
+ let ec_id = sample_ec_id("aBc123");
+ let consent = ConsentContext {
+ jurisdiction: Jurisdiction::UsState("CA".to_owned()),
+ gpc: true,
+ source: ConsentSource::Cookie,
+ ..Default::default()
+ };
+ let ec_context =
+ make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent);
+ let mut response = Response::new();
+ response.set_header("x-ts-ec", "stale");
+ response.set_header("x-ts-eids", "[]");
+ response.set_header("x-ts-ssp_x", "partner-uid-123");
+ response.set_header("x-ts-unrelated", "keep-me");
+
+ let partners = vec![make_partner("ssp_x")];
+ let test_registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ ec_finalize_response(
+ &settings,
+ &ec_context,
+ None,
+ &test_registry,
+ None,
+ None,
+ &mut response,
+ );
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "withdrawal should clear x-ts-ec header"
+ );
+ assert!(
+ response.get_header("x-ts-eids").is_none(),
+ "withdrawal should clear x-ts-eids header"
+ );
+ assert!(
+ response.get_header("x-ts-ssp_x").is_none(),
+ "withdrawal should clear registered partner header"
+ );
+ assert_eq!(
+ response.get_header_str("x-ts-unrelated"),
+ Some("keep-me"),
+ "withdrawal should preserve unrelated x-ts header"
+ );
+ let set_cookie = response
+ .get_header("set-cookie")
+ .expect("withdrawal should expire cookie")
+ .to_str()
+ .expect("set-cookie should be utf-8");
+ assert!(
+ set_cookie.contains("Max-Age=0"),
+ "withdrawal should set Max-Age=0"
+ );
+ }
+
+ #[test]
+ fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() {
+ let settings = create_test_settings();
+ let active_ec = sample_ec_id("activ1");
+ let cookie_ec = sample_ec_id("cook1e");
+ let ec_context = make_context(
+ Some(&active_ec),
+ Some(&cookie_ec),
+ true,
+ false,
+ Jurisdiction::NonRegulated,
+ );
+ let mut response = Response::new();
+
+ let test_registry = PartnerRegistry::empty();
+ ec_finalize_response(
+ &settings,
+ &ec_context,
+ None,
+ &test_registry,
+ None,
+ None,
+ &mut response,
+ );
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "returning user should not set x-ts-ec"
+ );
+ assert!(
+ response.get_header("set-cookie").is_none(),
+ "returning user should not refresh or repair cookie"
+ );
+ }
+
+ #[test]
+ fn finalize_returning_user_sets_no_header_or_cookie() {
+ let settings = create_test_settings();
+ let ec_id = sample_ec_id("mtch01");
+ let ec_context = make_context(
+ Some(&ec_id),
+ Some(&ec_id),
+ true,
+ false,
+ Jurisdiction::NonRegulated,
+ );
+ let mut response = Response::new();
+
+ let test_registry = PartnerRegistry::empty();
+ ec_finalize_response(
+ &settings,
+ &ec_context,
+ None,
+ &test_registry,
+ None,
+ None,
+ &mut response,
+ );
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "returning user should not set x-ts-ec"
+ );
+ assert!(
+ response.get_header("set-cookie").is_none(),
+ "returning user should not refresh cookie"
+ );
+ }
+
+ #[test]
+ fn finalize_generated_ec_without_kv_skips_cookie_and_header() {
+ let settings = create_test_settings();
+ let generated_ec = sample_ec_id("gen123");
+ let ec_context = make_context(
+ Some(&generated_ec),
+ None,
+ false,
+ true,
+ Jurisdiction::NonRegulated,
+ );
+ let mut response = Response::new();
+
+ let test_registry = PartnerRegistry::empty();
+ ec_finalize_response(
+ &settings,
+ &ec_context,
+ None,
+ &test_registry,
+ None,
+ None,
+ &mut response,
+ );
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "generated EC without KV should not set response header"
+ );
+ assert!(
+ response.get_header("set-cookie").is_none(),
+ "generated EC without KV should not set cookie"
+ );
+ }
+
+ #[test]
+ fn finalize_denied_without_cookie_is_noop() {
+ let settings = create_test_settings();
+ let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown);
+ let mut response = Response::new();
+
+ let test_registry = PartnerRegistry::empty();
+ ec_finalize_response(
+ &settings,
+ &ec_context,
+ None,
+ &test_registry,
+ None,
+ None,
+ &mut response,
+ );
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "should not set EC header"
+ );
+ assert!(
+ response.get_header("set-cookie").is_none(),
+ "should not mutate cookie when there is nothing to revoke"
+ );
+ }
+
+ #[test]
+ fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() {
+ let settings = create_test_settings();
+ let ec_id = sample_ec_id("unk001");
+ let ec_context = make_context(
+ Some(&ec_id),
+ Some(&ec_id),
+ true,
+ false,
+ Jurisdiction::Unknown,
+ );
+ let mut response = Response::new();
+ response.set_header("x-ts-ec", &ec_id);
+ response.set_header("x-ts-eids", "[]");
+
+ let test_registry = PartnerRegistry::empty();
+ ec_finalize_response(
+ &settings,
+ &ec_context,
+ None,
+ &test_registry,
+ None,
+ None,
+ &mut response,
+ );
+
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "should strip EC header when consent cannot be verified"
+ );
+ assert!(
+ response.get_header("x-ts-eids").is_none(),
+ "should strip EID header when consent cannot be verified"
+ );
+ assert!(
+ response.get_header("set-cookie").is_none(),
+ "should not expire the cookie without an explicit withdrawal signal"
+ );
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs
new file mode 100644
index 00000000..48068292
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/generation.rs
@@ -0,0 +1,341 @@
+//! Edge Cookie (EC) ID generation using HMAC.
+//!
+//! This module provides functionality for generating privacy-preserving EC IDs
+//! based on the client IP address and a secret key.
+
+use std::net::IpAddr;
+
+use error_stack::{Report, ResultExt};
+use hmac::{Hmac, Mac};
+use rand::Rng;
+use sha2::Sha256;
+
+use crate::error::TrustedServerError;
+use crate::settings::Settings;
+
+type HmacSha256 = Hmac;
+
+const ALPHANUMERIC_CHARSET: &[u8] =
+ b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+
+/// Normalizes an IP address for stable EC ID generation.
+///
+/// For IPv6 addresses, masks to /64 prefix to handle Privacy Extensions
+/// where devices rotate their interface identifier (lower 64 bits).
+/// The first 4 segments are hex-encoded without separators.
+/// IPv4 addresses are returned unchanged.
+///
+/// # Stability
+///
+/// The output format is a stable contract — EC hashes stored in KV depend
+/// on it. Changing the format would invalidate all existing EC identities.
+/// - **IPv4:** decimal-dotted notation (e.g. `"192.168.1.1"`)
+/// - **IPv6:** first 4 segments as zero-padded lowercase hex without
+/// separators (e.g. `"20010db885a30000"`)
+fn normalize_ip(ip: IpAddr) -> String {
+ match ip {
+ IpAddr::V4(ipv4) => ipv4.to_string(),
+ IpAddr::V6(ipv6) => {
+ let segments = ipv6.segments();
+ // Keep only the first 4 segments (64 bits) for /64 prefix.
+ // Concatenate as zero-padded hex without separators.
+ format!(
+ "{:04x}{:04x}{:04x}{:04x}",
+ segments[0], segments[1], segments[2], segments[3]
+ )
+ }
+ }
+}
+
+/// Generates a random alphanumeric string of the specified length.
+///
+/// Fastly Compute's `wasm32-wasip1` runtime supplies OS randomness through
+/// WASI for `rand::thread_rng`; the CI wasm release build verifies that this
+/// entropy path remains available for the EC suffix contract.
+fn generate_random_suffix(length: usize) -> String {
+ let mut rng = rand::thread_rng();
+ (0..length)
+ .map(|_| {
+ let idx = rng.gen_range(0..ALPHANUMERIC_CHARSET.len());
+ ALPHANUMERIC_CHARSET[idx] as char
+ })
+ .collect()
+}
+
+/// Generates a fresh EC ID from a pre-captured client IP string.
+///
+/// Uses only the client IP (not user-agent or other headers) intentionally:
+/// EC IDs are meant to be simple, privacy-preserving identifiers — not
+/// high-entropy fingerprints. The random suffix provides per-cookie
+/// uniqueness for users behind the same NAT/proxy.
+///
+/// Creates an HMAC-SHA256-based ID using the configured secret key and
+/// the client IP address, then appends a random suffix for additional
+/// uniqueness. The resulting format is `{64hex}.{6alnum}`.
+///
+/// **Important:** `client_ip` must be pre-normalized via [`extract_client_ip`].
+/// Raw IPv6 addresses produce different hashes than their normalized /64
+/// form, which would create duplicate identity graph entries.
+///
+/// # Errors
+///
+/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails
+pub fn generate_ec_id(
+ settings: &Settings,
+ client_ip: &str,
+) -> Result> {
+ let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes())
+ .change_context(TrustedServerError::EdgeCookie {
+ message: "Failed to create HMAC instance".to_string(),
+ })?;
+ mac.update(client_ip.as_bytes());
+ let hmac_hash = hex::encode(mac.finalize().into_bytes());
+
+ // Append random 6-character alphanumeric suffix for additional uniqueness.
+ let random_suffix = generate_random_suffix(6);
+ let ec_id = format!("{hmac_hash}.{random_suffix}");
+
+ log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id));
+
+ Ok(ec_id)
+}
+
+/// Extracts and normalizes the client IP from a request.
+///
+/// Returns the normalized IP as a string suitable for HMAC input.
+///
+/// # Errors
+///
+/// Returns [`TrustedServerError::EdgeCookie`] when the client IP is unavailable
+/// (e.g. in certain test or proxy configurations). EC generation requires
+/// a valid client IP — there is no fallback.
+pub fn extract_client_ip(req: &fastly::Request) -> Result> {
+ req.get_client_ip_addr().map(normalize_ip).ok_or_else(|| {
+ Report::new(TrustedServerError::EdgeCookie {
+ message: "Client IP required for EC generation but unavailable".to_string(),
+ })
+ })
+}
+
+/// Extracts the stable 64-character hex prefix from an EC ID.
+///
+/// Given an EC ID in `{64hex}.{6alnum}` format, returns the `{64hex}`
+/// portion. If the ID does not contain a dot separator, returns the
+/// entire string.
+#[must_use]
+pub fn ec_hash(ec_id: &str) -> &str {
+ // Find the dot separator; if absent, return the entire string.
+ match ec_id.find('.') {
+ Some(pos) => &ec_id[..pos],
+ None => ec_id,
+ }
+}
+
+/// Normalizes an EC ID for use as a KV key by lowercasing the hash prefix.
+///
+/// `hex::encode` (used in [`generate_ec_id`]) always produces lowercase hex,
+/// so internal EC IDs are already lowercase. This normalization is a
+/// defense-in-depth measure for EC IDs submitted by external partners
+/// (via batch sync) that may use uppercase hex.
+#[must_use]
+pub fn normalize_ec_id_for_kv(ec_id: &str) -> String {
+ let mut parts = ec_id.splitn(2, '.');
+ let hash = parts.next().unwrap_or_default();
+ let suffix = parts.next().unwrap_or_default();
+ format!("{}.{}", hash.to_ascii_lowercase(), suffix)
+}
+
+/// Checks whether a string is a valid 64-character hex EC hash prefix.
+///
+/// Used by batch sync, finalize, and other modules that handle the
+/// `{64hex}` portion of an EC ID independently. Accepts both uppercase
+/// and lowercase hex; callers that require a specific case should
+/// normalize before comparison.
+#[must_use]
+pub fn is_valid_ec_hash(value: &str) -> bool {
+ value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
+}
+
+/// Checks whether a string matches the expected EC ID format.
+///
+/// The format is `{64hex}.{6alnum}` where the first part is a 64-character
+/// **lowercase** hex string and the second part is a 6-character alphanumeric
+/// string. Only lowercase hex is accepted; callers must normalize before
+/// validation to prevent duplicate KV keys from case-variant EC IDs. The HMAC
+/// prefix is lowercase because it comes from `hex::encode`; the random suffix
+/// allows mixed-case alphanumeric characters by construction.
+#[must_use]
+pub fn is_valid_ec_id(value: &str) -> bool {
+ let mut parts = value.split('.');
+ let Some(hmac_part) = parts.next() else {
+ return false;
+ };
+ let Some(suffix_part) = parts.next() else {
+ return false;
+ };
+
+ // Must have exactly two segments.
+ if parts.next().is_some() {
+ return false;
+ }
+
+ hmac_part.len() == 64
+ && suffix_part.len() == 6
+ && hmac_part
+ .bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+ && suffix_part.bytes().all(|b| b.is_ascii_alphanumeric())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::{Ipv4Addr, Ipv6Addr};
+
+ use crate::test_support::tests::create_test_settings;
+
+ #[test]
+ fn normalize_ipv4_unchanged() {
+ let ipv4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100));
+ assert_eq!(normalize_ip(ipv4), "192.168.1.100");
+ }
+
+ #[test]
+ fn normalize_ipv6_masks_to_64_no_separators() {
+ let ipv6 = IpAddr::V6(Ipv6Addr::new(
+ 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234,
+ ));
+ assert_eq!(
+ normalize_ip(ipv6),
+ "20010db885a30000",
+ "should concatenate first 4 segments as zero-padded hex without separators"
+ );
+ }
+
+ #[test]
+ fn normalize_ipv6_different_suffix_same_prefix() {
+ // Two IPv6 addresses with same /64 prefix but different interface identifiers
+ // (simulating Privacy Extensions rotation).
+ let ipv6_a = IpAddr::V6(Ipv6Addr::new(
+ 0x2001, 0x0db8, 0xabcd, 0x0001, 0x1111, 0x2222, 0x3333, 0x4444,
+ ));
+ let ipv6_b = IpAddr::V6(Ipv6Addr::new(
+ 0x2001, 0x0db8, 0xabcd, 0x0001, 0xaaaa, 0xbbbb, 0xcccc, 0xdddd,
+ ));
+ assert_eq!(
+ normalize_ip(ipv6_a),
+ normalize_ip(ipv6_b),
+ "should normalize to the same /64 prefix"
+ );
+ assert_eq!(normalize_ip(ipv6_a), "20010db8abcd0001");
+ }
+
+ #[test]
+ fn generate_produces_valid_format() {
+ let settings = create_test_settings();
+ let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID");
+ assert!(
+ is_valid_ec_id(&ec_id),
+ "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}"
+ );
+ }
+
+ #[test]
+ fn generate_same_ip_produces_consistent_hash_prefix() {
+ let settings = create_test_settings();
+ let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID");
+ let second =
+ generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID");
+
+ assert_eq!(
+ ec_hash(&first),
+ ec_hash(&second),
+ "same IP and passphrase should produce the same HMAC prefix"
+ );
+ assert_ne!(
+ first, second,
+ "random suffix should differ between generated EC IDs"
+ );
+ }
+
+ #[test]
+ fn ec_hash_extracts_prefix() {
+ let id = format!("{}.Ab12z9", "a".repeat(64));
+ assert_eq!(ec_hash(&id), "a".repeat(64));
+ }
+
+ #[test]
+ fn ec_hash_returns_full_string_without_dot() {
+ assert_eq!(ec_hash("nodot"), "nodot");
+ }
+
+ #[test]
+ fn is_valid_ec_hash_accepts_64_hex() {
+ assert!(is_valid_ec_hash(&"a".repeat(64)));
+ assert!(is_valid_ec_hash(&"0123456789abcdef".repeat(4)));
+ }
+
+ #[test]
+ fn is_valid_ec_hash_accepts_uppercase_hex() {
+ assert!(
+ is_valid_ec_hash(&"A".repeat(64)),
+ "should accept uppercase hex (callers normalize before KV lookup)"
+ );
+ }
+
+ #[test]
+ fn is_valid_ec_hash_rejects_wrong_length() {
+ assert!(!is_valid_ec_hash(&"a".repeat(63)));
+ assert!(!is_valid_ec_hash(&"a".repeat(65)));
+ assert!(!is_valid_ec_hash(""));
+ }
+
+ #[test]
+ fn is_valid_ec_hash_rejects_non_hex() {
+ let mut hash = "a".repeat(64);
+ hash.replace_range(0..1, "g");
+ assert!(!is_valid_ec_hash(&hash));
+ }
+
+ #[test]
+ fn is_valid_ec_id_accepts_valid() {
+ let value = format!("{}.Ab12z9", "a".repeat(64));
+ assert!(is_valid_ec_id(&value), "should accept a valid EC ID format");
+ }
+
+ #[test]
+ fn is_valid_ec_id_rejects_missing_suffix() {
+ let missing_suffix = "a".repeat(64);
+ assert!(
+ !is_valid_ec_id(&missing_suffix),
+ "should reject missing suffix"
+ );
+ }
+
+ #[test]
+ fn is_valid_ec_id_rejects_invalid_hex() {
+ let invalid_hex = format!("{}.Ab12z9", "a".repeat(63) + "g");
+ assert!(
+ !is_valid_ec_id(&invalid_hex),
+ "should reject non-hex HMAC content"
+ );
+ }
+
+ #[test]
+ fn is_valid_ec_id_rejects_invalid_suffix() {
+ let invalid_suffix = format!("{}.ab-129", "a".repeat(64));
+ assert!(
+ !is_valid_ec_id(&invalid_suffix),
+ "should reject non-alphanumeric suffix"
+ );
+ }
+
+ #[test]
+ fn is_valid_ec_id_rejects_extra_segments() {
+ let extra_segment = format!("{}.Ab12z9.zz", "a".repeat(64));
+ assert!(
+ !is_valid_ec_id(&extra_segment),
+ "should reject extra segments"
+ );
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs
new file mode 100644
index 00000000..b0fc8a49
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/identify.rs
@@ -0,0 +1,612 @@
+//! Identity lookup endpoint (`GET /_ts/api/v1/identify`).
+//!
+//! Partners authenticate with a Bearer token and receive only their own
+//! synced UID for the active EC ID.
+
+use error_stack::{Report, ResultExt};
+use fastly::http::{header, StatusCode};
+use fastly::{Request, Response};
+use url::Url;
+
+use super::auth::authenticate_bearer;
+use super::consent::ec_consent_granted;
+use crate::error::TrustedServerError;
+use crate::openrtb::{Eid, Uid};
+use crate::settings::Settings;
+
+use super::kv::KvIdentityGraph;
+use super::log_id;
+use super::registry::PartnerRegistry;
+use super::EcContext;
+
+/// Handles `GET /_ts/api/v1/identify`.
+///
+/// Requires Bearer token authentication. Returns only the requesting
+/// partner's UID for the active EC ID.
+///
+/// # Errors
+///
+/// Returns [`TrustedServerError`] for response serialization issues.
+pub fn handle_identify(
+ settings: &Settings,
+ kv: &KvIdentityGraph,
+ registry: &PartnerRegistry,
+ req: &Request,
+ ec_context: &EcContext,
+) -> Result> {
+ let allowed_origin = match classify_origin(req, settings) {
+ CorsDecision::Denied => {
+ return Ok(apply_identify_cache_headers(Response::from_status(
+ StatusCode::FORBIDDEN,
+ )));
+ }
+ CorsDecision::NoOrigin => None,
+ CorsDecision::Allowed(origin) => Some(origin),
+ };
+
+ // Authenticate via Bearer token.
+ let Some(partner) = authenticate_bearer(registry, req) else {
+ return json_response_with_origin(
+ StatusCode::UNAUTHORIZED,
+ &serde_json::json!({ "error": "invalid_token" }),
+ allowed_origin.as_deref(),
+ );
+ };
+
+ if !ec_consent_granted(ec_context.consent()) {
+ return json_response_with_origin(
+ StatusCode::FORBIDDEN,
+ &serde_json::json!({ "consent": "denied" }),
+ allowed_origin.as_deref(),
+ );
+ }
+
+ let Some(ec_id) = ec_context.ec_value() else {
+ let response = apply_identify_cache_headers(Response::from_status(StatusCode::NO_CONTENT));
+ return Ok(apply_cors_headers_if_allowed(
+ response,
+ allowed_origin.as_deref(),
+ ));
+ };
+
+ let mut degraded = false;
+ let mut uid: Option = None;
+ let mut cluster_size: Option = None;
+
+ match kv.get(ec_id) {
+ Ok(Some((entry, generation))) => {
+ // Extract only this partner's UID.
+ if let Some(partner_uid) = entry.ids.get(&partner.id) {
+ if !partner_uid.uid.is_empty() {
+ uid = Some(partner_uid.uid.clone());
+ }
+ }
+
+ // Evaluate cluster size lazily for identify responses. Existing
+ // stored cluster_size values are reused without a prefix-list call.
+ match kv.evaluate_cluster(ec_id, &entry, generation) {
+ Ok(size) => {
+ cluster_size = size;
+ }
+ Err(err) => {
+ log::warn!("Cluster evaluation failed for '{}': {err:?}", log_id(ec_id));
+ }
+ }
+ }
+ Ok(None) => {}
+ Err(err) => {
+ log::warn!(
+ "Identify KV read failed for EC ID '{}': {err:?}",
+ log_id(ec_id)
+ );
+ degraded = true;
+ }
+ }
+
+ let eid = uid.as_ref().map(|u| Eid {
+ source: partner.source_domain.clone(),
+ uids: vec![Uid {
+ id: u.clone(),
+ atype: Some(partner.openrtb_atype),
+ ext: None,
+ }],
+ });
+
+ let body = IdentifyResponse {
+ ec: ec_id.to_owned(),
+ consent: "ok".to_owned(),
+ degraded,
+ partner_id: partner.id.clone(),
+ uid,
+ eid,
+ cluster_size,
+ };
+
+ json_response_with_origin(StatusCode::OK, &body, allowed_origin.as_deref())
+}
+
+/// Handles `OPTIONS /_ts/api/v1/identify` CORS preflight.
+///
+/// # Errors
+///
+/// Returns [`TrustedServerError`] when response construction fails.
+pub fn cors_preflight_identify(
+ settings: &Settings,
+ req: &Request,
+) -> Result> {
+ let mut response = match classify_origin(req, settings) {
+ CorsDecision::Denied => Response::from_status(StatusCode::FORBIDDEN),
+ CorsDecision::NoOrigin => Response::from_status(StatusCode::OK),
+ CorsDecision::Allowed(origin) => {
+ let mut response = Response::from_status(StatusCode::OK);
+ apply_cors_headers(&mut response, &origin);
+ response
+ }
+ };
+
+ response.set_body(Vec::new());
+ Ok(apply_identify_cache_headers(response))
+}
+
+#[derive(serde::Serialize)]
+struct IdentifyResponse {
+ ec: String,
+ consent: String,
+ degraded: bool,
+ partner_id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ uid: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ eid: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ cluster_size: Option,
+}
+
+fn json_response_with_origin(
+ status: StatusCode,
+ body: &T,
+ allowed_origin: Option<&str>,
+) -> Result> {
+ let body = serde_json::to_string(body).change_context(TrustedServerError::EdgeCookie {
+ message: "Failed to serialize identify response".to_owned(),
+ })?;
+
+ let response = Response::from_status(status)
+ .with_content_type(fastly::mime::APPLICATION_JSON)
+ .with_body(body);
+ let response = apply_identify_cache_headers(response);
+
+ Ok(apply_cors_headers_if_allowed(response, allowed_origin))
+}
+
+enum CorsDecision {
+ NoOrigin,
+ Allowed(String),
+ Denied,
+}
+
+fn classify_origin(req: &Request, settings: &Settings) -> CorsDecision {
+ let Some(origin) = req.get_header(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
+ return CorsDecision::NoOrigin;
+ };
+
+ let Ok(origin_url) = Url::parse(origin) else {
+ return CorsDecision::Denied;
+ };
+
+ if origin_url.scheme() != "https" {
+ return CorsDecision::Denied;
+ }
+
+ let Some(host) = origin_url.host_str() else {
+ return CorsDecision::Denied;
+ };
+
+ let publisher_host = settings
+ .publisher
+ .domain
+ .trim_end_matches('.')
+ .to_ascii_lowercase();
+
+ if origin_authority_contains_uppercase_host(origin) {
+ return CorsDecision::Denied;
+ }
+
+ let host = host.to_ascii_lowercase();
+ if host == publisher_host || host.ends_with(&format!(".{publisher_host}")) {
+ return CorsDecision::Allowed(origin.to_owned());
+ }
+
+ CorsDecision::Denied
+}
+
+fn origin_authority_contains_uppercase_host(origin: &str) -> bool {
+ let Some(after_scheme) = origin.strip_prefix("https://") else {
+ return false;
+ };
+ let authority = after_scheme
+ .split(['/', '?', '#'])
+ .next()
+ .unwrap_or(after_scheme);
+ let host_port = authority
+ .rsplit_once('@')
+ .map_or(authority, |(_, host_port)| host_port);
+ let host = host_port
+ .split_once(':')
+ .map_or(host_port, |(host, _)| host);
+
+ host.bytes().any(|byte| byte.is_ascii_uppercase())
+}
+
+fn apply_identify_cache_headers(mut response: Response) -> Response {
+ response.set_header(header::CACHE_CONTROL, "no-store");
+ response.set_header(header::PRAGMA, "no-cache");
+ response.set_header(header::VARY, "Origin, Authorization");
+ response
+}
+
+fn apply_cors_headers_if_allowed(mut response: Response, allowed_origin: Option<&str>) -> Response {
+ if let Some(origin) = allowed_origin {
+ apply_cors_headers(&mut response, origin);
+ }
+ response
+}
+
+fn apply_cors_headers(response: &mut Response, origin: &str) {
+ response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+ response.set_header(header::ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+ response.set_header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS");
+ response.set_header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Authorization");
+ response.set_header(header::ACCESS_CONTROL_MAX_AGE, "600");
+ response.set_header(header::VARY, "Origin, Authorization");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::consent::jurisdiction::Jurisdiction;
+ use crate::consent::types::{ConsentContext, ConsentSource};
+ use crate::ec::registry::PartnerRegistry;
+ use crate::redacted::Redacted;
+ use crate::settings::EcPartner;
+ use crate::test_support::tests::create_test_settings;
+
+ const VALID_API_TOKEN: &str = "identify-test-token-32-bytes-min";
+
+ fn assert_no_store(response: &Response) {
+ assert_eq!(
+ response.get_header_str(header::CACHE_CONTROL),
+ Some("no-store"),
+ "identify responses should not be cached"
+ );
+ }
+
+ fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext {
+ let consent = ConsentContext {
+ jurisdiction,
+ source: ConsentSource::Cookie,
+ ..ConsentContext::default()
+ };
+ EcContext::new_for_test(ec_value.map(str::to_owned), consent)
+ }
+
+ fn make_test_partner(id: &str, api_token: &str) -> EcPartner {
+ EcPartner {
+ id: id.to_owned(),
+ name: format!("Partner {id}"),
+ source_domain: format!("{id}.example.com"),
+ openrtb_atype: EcPartner::default_openrtb_atype(),
+ bidstream_enabled: true,
+ api_token: Redacted::new(api_token.to_owned()),
+ batch_rate_limit: EcPartner::default_batch_rate_limit(),
+ pull_sync_enabled: false,
+ pull_sync_url: None,
+ pull_sync_allowed_domains: vec![],
+ pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(),
+ pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(),
+ ts_pull_token: None,
+ }
+ }
+
+ #[test]
+ fn classify_origin_accepts_publisher_subdomain() {
+ let settings = create_test_settings();
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("origin", "https://www.test-publisher.com");
+
+ let decision = classify_origin(&req, &settings);
+ assert!(
+ matches!(decision, CorsDecision::Allowed(_)),
+ "should allow publisher subdomain origin"
+ );
+ }
+
+ #[test]
+ fn classify_origin_rejects_mismatch() {
+ let settings = create_test_settings();
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("origin", "https://evil.com");
+
+ let decision = classify_origin(&req, &settings);
+ assert!(
+ matches!(decision, CorsDecision::Denied),
+ "should deny mismatched origin"
+ );
+ }
+
+ #[test]
+ fn classify_origin_rejects_mixed_case_publisher_host() {
+ let settings = create_test_settings();
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("origin", "https://Foo.test-publisher.com");
+
+ let decision = classify_origin(&req, &settings);
+ assert!(
+ matches!(decision, CorsDecision::Denied),
+ "should deny mixed-case origin hosts instead of reflecting a value browsers reject"
+ );
+ }
+
+ #[test]
+ fn classify_origin_rejects_http_scheme() {
+ let settings = create_test_settings();
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("origin", "http://www.test-publisher.com");
+
+ let decision = classify_origin(&req, &settings);
+ assert!(
+ matches!(decision, CorsDecision::Denied),
+ "should deny non-https publisher origin"
+ );
+ }
+
+ #[test]
+ fn classify_origin_allows_absent_origin_header() {
+ let settings = create_test_settings();
+ let req = Request::new("GET", "https://edge.test-publisher.com/identify");
+
+ let decision = classify_origin(&req, &settings);
+ assert!(
+ matches!(decision, CorsDecision::NoOrigin),
+ "should allow no-origin requests"
+ );
+ }
+
+ #[test]
+ fn handle_identify_rejects_missing_bearer_token() {
+ let settings = create_test_settings();
+ let kv = KvIdentityGraph::new("missing_store");
+ let registry = PartnerRegistry::empty();
+ let req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
+
+ let mut response = handle_identify(&settings, &kv, ®istry, &req, &ec_context)
+ .expect("should construct unauthorized response");
+
+ assert_eq!(
+ response.get_header_str(header::ACCESS_CONTROL_ALLOW_ORIGIN),
+ None,
+ "should omit CORS headers when Origin is absent"
+ );
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::UNAUTHORIZED,
+ "should return 401 without Bearer token"
+ );
+ assert_no_store(&response);
+ let body = serde_json::from_slice::(&response.take_body_bytes())
+ .expect("should decode JSON body");
+ assert_eq!(
+ body["error"], "invalid_token",
+ "should return invalid_token error"
+ );
+ }
+
+ #[test]
+ fn handle_identify_rejects_invalid_bearer_token() {
+ let settings = create_test_settings();
+ let kv = KvIdentityGraph::new("missing_store");
+ let partners = vec![make_test_partner("ssp_x", VALID_API_TOKEN)];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("authorization", "Bearer wrong-token");
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
+
+ let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context)
+ .expect("should construct unauthorized response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::UNAUTHORIZED,
+ "should return 401 for invalid Bearer token"
+ );
+ assert_no_store(&response);
+ }
+
+ #[test]
+ fn handle_identify_denied_consent_returns_403() {
+ let settings = create_test_settings();
+ let kv = KvIdentityGraph::new("missing_store");
+ let partners = vec![make_test_partner("ssp_x", VALID_API_TOKEN)];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}"));
+ let ec_context = make_ec_context(Jurisdiction::Unknown, None);
+
+ let mut response = handle_identify(&settings, &kv, ®istry, &req, &ec_context)
+ .expect("should construct denied response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::FORBIDDEN,
+ "should return 403 when consent denies EC"
+ );
+ assert_no_store(&response);
+ let body = serde_json::from_slice::(&response.take_body_bytes())
+ .expect("should decode JSON body");
+ assert_eq!(
+ body,
+ serde_json::json!({ "consent": "denied" }),
+ "should return denied consent payload"
+ );
+ }
+
+ #[test]
+ fn handle_identify_without_ec_returns_204() {
+ let settings = create_test_settings();
+ let kv = KvIdentityGraph::new("missing_store");
+ let partners = vec![make_test_partner("ssp_x", VALID_API_TOKEN)];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}"));
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
+
+ let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context)
+ .expect("should construct no-content response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::NO_CONTENT,
+ "should return 204 when EC is unavailable"
+ );
+ assert_no_store(&response);
+ }
+
+ #[test]
+ fn handle_identify_kv_failure_sets_degraded_true() {
+ let settings = create_test_settings();
+ let kv = KvIdentityGraph::new("missing_store");
+ let partners = vec![make_test_partner("ssp_x", VALID_API_TOKEN)];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}"));
+ let ec_id = format!("{}.ABC123", "a".repeat(64));
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
+
+ let mut response = handle_identify(&settings, &kv, ®istry, &req, &ec_context)
+ .expect("should construct degraded identify response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::OK,
+ "should return 200 on degraded KV read"
+ );
+ assert_no_store(&response);
+ let body = serde_json::from_slice::(&response.take_body_bytes())
+ .expect("should decode identify response JSON");
+
+ assert_eq!(body["ec"], ec_id, "should echo EC in body");
+ assert!(
+ response.get_header("x-ts-ec").is_none(),
+ "should not emit x-ts-ec header"
+ );
+ assert_eq!(body["partner_id"], "ssp_x", "should echo partner ID");
+ assert_eq!(
+ body["degraded"],
+ serde_json::Value::Bool(true),
+ "should mark response as degraded when KV read fails"
+ );
+ assert!(
+ body.get("uid").is_none(),
+ "uid should be omitted when KV read fails"
+ );
+ assert!(
+ body.get("eid").is_none(),
+ "eid should be omitted when KV read fails"
+ );
+ }
+
+ #[test]
+ fn handle_identify_denies_mismatched_browser_origin() {
+ let settings = create_test_settings();
+ let kv = KvIdentityGraph::new("missing_store");
+ let partners = vec![make_test_partner("ssp_x", VALID_API_TOKEN)];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}"));
+ req.set_header("origin", "https://evil.example");
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
+
+ let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context)
+ .expect("should construct forbidden response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::FORBIDDEN,
+ "should reject GET from non-publisher origin"
+ );
+ assert_no_store(&response);
+ }
+
+ #[test]
+ fn handle_identify_allows_browser_origin_and_reflects_cors_headers() {
+ let settings = create_test_settings();
+ let kv = KvIdentityGraph::new("missing_store");
+ let partners = vec![make_test_partner("ssp_x", VALID_API_TOKEN)];
+ let registry = PartnerRegistry::from_config(&partners).expect("should build registry");
+ let mut req = Request::new("GET", "https://edge.test-publisher.com/identify");
+ req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}"));
+ req.set_header("origin", "https://www.test-publisher.com");
+ let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
+
+ let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context)
+ .expect("should construct no-content response with CORS headers");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::NO_CONTENT,
+ "should preserve identify response status for allowed browser origin"
+ );
+ assert_no_store(&response);
+ assert_eq!(
+ response.get_header_str(header::ACCESS_CONTROL_ALLOW_ORIGIN),
+ Some("https://www.test-publisher.com"),
+ "should reflect allowed browser origin on GET responses"
+ );
+ assert_eq!(
+ response.get_header_str(header::VARY),
+ Some("Origin, Authorization"),
+ "should vary on identity request inputs for browser-direct identify responses"
+ );
+ }
+
+ #[test]
+ fn identify_preflight_denies_mismatched_origin() {
+ let settings = create_test_settings();
+ let mut req = Request::new("OPTIONS", "https://edge.test-publisher.com/identify");
+ req.set_header("origin", "https://evil.example");
+
+ let response =
+ cors_preflight_identify(&settings, &req).expect("should construct preflight response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::FORBIDDEN,
+ "should reject preflight from non-publisher origin"
+ );
+ assert_no_store(&response);
+ }
+
+ #[test]
+ fn identify_preflight_allows_publisher_origin() {
+ let settings = create_test_settings();
+ let mut req = Request::new("OPTIONS", "https://edge.test-publisher.com/identify");
+ req.set_header("origin", "https://www.test-publisher.com");
+
+ let response =
+ cors_preflight_identify(&settings, &req).expect("should construct preflight response");
+
+ assert_eq!(
+ response.get_status(),
+ StatusCode::OK,
+ "should allow preflight from publisher origin"
+ );
+ assert_no_store(&response);
+ assert_eq!(
+ response.get_header_str(header::VARY),
+ Some("Origin, Authorization"),
+ "should vary on identity request inputs for preflight"
+ );
+ }
+}
diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs
new file mode 100644
index 00000000..a431892e
--- /dev/null
+++ b/crates/trusted-server-core/src/ec/kv.rs
@@ -0,0 +1,832 @@
+//! KV identity graph operations.
+//!
+//! This module provides [`KvIdentityGraph`] which wraps a Fastly KV Store
+//! and implements the read-modify-write operations for the EC identity graph.
+//!
+//! All methods return `Result` — callers decide whether to swallow errors
+//! (organic request paths) or propagate them (sync endpoints). See the
+//! per-operation error handling policy in the spec §7.5.
+
+use std::time::Duration;
+
+use error_stack::{Report, ResultExt};
+use fastly::kv_store::{InsertMode, KVStore};
+
+use crate::error::TrustedServerError;
+
+use super::current_timestamp;
+use super::generation::ec_hash;
+use super::kv_types::{KvEntry, KvMetadata, KvNetwork};
+
+/// Maximum number of CAS retry attempts before giving up.
+const MAX_CAS_RETRIES: u32 = 5;
+
+/// Maximum number of keys to request when counting hash-prefix matches
+/// for cluster size evaluation. Anything above this is clearly a large
+/// shared network; the exact count doesn't matter.
+const CLUSTER_LIST_LIMIT: u32 = 100;
+
+/// TTL for live entries (1 year), matching the EC cookie `Max-Age`.
+const ENTRY_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60);
+
+/// TTL for withdrawal tombstones (24 hours).
+const TOMBSTONE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
+
+/// Outcome of an [`KvIdentityGraph::upsert_partner_id_if_exists`] call.
+///
+/// Like [`KvIdentityGraph::upsert_partner_id`], this method fails closed when
+/// the root entry is missing. This enum encodes the per-mapping rejection
+/// reasons needed by the S2S batch sync endpoint.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UpsertResult {
+ /// The partner ID was successfully written.
+ Written,
+ /// The KV key does not exist — S2S must not create new entries.
+ NotFound,
+ /// The entry's `consent.ok` is `false` (withdrawal tombstone).
+ ConsentWithdrawn,
+ /// The partner ID already had the requested UID, so no write was needed.
+ Unchanged,
+}
+
+use super::log_id;
+
+/// Wraps a Fastly KV Store for EC identity graph operations.
+///
+/// Each EC ID (`{64hex}.{6alnum}`) maps to a JSON-encoded [`KvEntry`]
+/// containing consent state, geo location, and accumulated partner IDs.
+///
+/// Methods use optimistic concurrency (generation markers) for safe
+/// read-modify-write operations on concurrent requests.
+#[derive(Debug)]
+pub struct KvIdentityGraph {
+ store_name: String,
+}
+
+impl KvIdentityGraph {
+ /// Creates a new identity graph backed by the named KV store.
+ #[must_use]
+ pub fn new(store_name: impl Into) -> Self {
+ Self {
+ store_name: store_name.into(),
+ }
+ }
+
+ /// Returns the configured store name.
+ #[must_use]
+ pub fn store_name(&self) -> &str {
+ &self.store_name
+ }
+
+ /// Opens the underlying Fastly KV store.
+ fn open_store(&self) -> Result> {
+ KVStore::open(&self.store_name)
+ .change_context(TrustedServerError::KvStore {
+ store_name: self.store_name.clone(),
+ message: "Failed to open KV store".to_owned(),
+ })?
+ .ok_or_else(|| {
+ Report::new(TrustedServerError::KvStore {
+ store_name: self.store_name.clone(),
+ message: "KV store not found".to_owned(),
+ })
+ })
+ }
+
+ /// Serializes an entry body and metadata for insertion.
+ fn serialize_entry(
+ entry: &KvEntry,
+ store_name: &str,
+ ) -> Result<(String, String), Report> {
+ entry.validate().map_err(|message| {
+ Report::new(TrustedServerError::KvStore {
+ store_name: store_name.to_owned(),
+ message: format!("Refusing to serialize invalid KV entry: {message}"),
+ })
+ })?;
+
+ let body = serde_json::to_string(entry).change_context(TrustedServerError::KvStore {
+ store_name: store_name.to_owned(),
+ message: "Failed to serialize KV entry body".to_owned(),
+ })?;
+ let meta = KvMetadata::from_entry(entry);
+ let meta_str =
+ serde_json::to_string(&meta).change_context(TrustedServerError::KvStore {
+ store_name: store_name.to_owned(),
+ message: "Failed to serialize KV entry metadata".to_owned(),
+ })?;
+ Ok((body, meta_str))
+ }
+
+ /// Reads the full entry and its generation marker for CAS writes.
+ ///
+ /// Returns `Ok(None)` when the key does not exist.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TrustedServerError::KvStore`] on store open or read failure.
+ pub fn get(&self, ec_id: &str) -> Result