From 0a2a00aaf63e76bbb3669d4379de96e821eb6840 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 12 Nov 2026 19:33:38 +0530 Subject: [PATCH 1/4] Handle SharedID the way Prebid defines it Read the cookie named by the Prebid sharedId module's storage.name, default the source to pubcid.org with atype 1, and keep a stored SharedID for as long as the row lives instead of letting a different cookie replace it after a day. Signed-off-by: dhruv8sh --- .../src/ec/coordinator/mod.rs | 84 ++++++++++++ crates/trusted-server-core/src/ec/provider.rs | 15 ++ crates/trusted-server-core/src/settings.rs | 27 +++- .../src/lib.rs | 129 +++++++++++++++++- .../src/sharedid.rs | 88 +++++++++--- trusted-server.example.toml | 22 +-- 6 files changed, 332 insertions(+), 33 deletions(-) diff --git a/crates/trusted-server-core/src/ec/coordinator/mod.rs b/crates/trusted-server-core/src/ec/coordinator/mod.rs index 99821f569..f930532d1 100644 --- a/crates/trusted-server-core/src/ec/coordinator/mod.rs +++ b/crates/trusted-server-core/src/ec/coordinator/mod.rs @@ -587,6 +587,14 @@ impl IdentityCoordinator for ProviderCoordinator

{ }); recount_found(&mut found); } + // A first-party ID kept for the row's lifetime is not + // replaced by a different cookie value. + if registered.provider.keeps_stored_uids() { + found.entries.retain(|(source, _)| { + !Self::holds_live_uid(entry, source.as_str(), lookup.now) + }); + recount_found(&mut found); + } if matches!(found.status, LookupStatus::Found { .. }) { // An ID in the vendor's cookies means the visitor // opted back in, so a remembered opt-out is @@ -2931,6 +2939,82 @@ mod tests { assert!(transport.sent.borrow().is_empty(), "should send nothing"); } + struct StableCookieProvider(EchoProvider); + + impl IdentityProvider for StableCookieProvider { + fn capabilities(&self) -> &ProviderCapabilities { + self.0.capabilities() + } + + fn build_request( + &self, + lookup: &LookupInput<'_>, + ) -> Result> { + self.0.build_request(lookup) + } + + fn parse_response<'body>( + &self, + response: ProviderResponse<'body>, + ) -> Result, Report> { + self.0.parse_response(response) + } + + fn read_request_cookies<'cookie>( + &self, + cookies: RequestCookies<'cookie>, + ) -> CookieRead<'cookie> { + self.0.read_request_cookies(cookies) + } + + fn keeps_stored_uids(&self) -> bool { + true + } + } + + #[test] + fn a_different_cookie_does_not_replace_a_kept_stored_uid() { + // Arrange: the row holds a live UID; the cookie carries another, + // longer-lived one. + let coordinator = coordinator_with(RegisteredProvider::new( + provider_config(""), + StableCookieProvider(cookie_provider(SourceInput::Anonymous, Some("vendor_id"))), + )); + let transport = CannedTransport::new(ok_reply); + + // Act + let results = run( + &coordinator, + &transport, + &snapshot_holding(record_until("p-2", 4000)), + &granting(), + ); + + // Assert + assert_eq!( + only_status(&results), + LookupStatus::Skipped(SkipReason::NotDue), + "should keep the stored UID over a different cookie" + ); + assert!(transport.sent.borrow().is_empty(), "should send nothing"); + } + + #[test] + fn a_kept_source_without_a_stored_uid_takes_the_cookie() { + let coordinator = coordinator_with(RegisteredProvider::new( + provider_config(""), + StableCookieProvider(cookie_provider(SourceInput::Anonymous, Some("vendor_id"))), + )); + let transport = CannedTransport::new(ok_reply); + + let results = run(&coordinator, &transport, &live_row(), &granting()); + + assert!( + matches!(only_status(&results), LookupStatus::Found { kept: 1, .. }), + "should store the cookie UID when none is held" + ); + } + #[test] fn a_newer_cookie_replaces_an_older_stored_uid() { let coordinator = coordinator_with(RegisteredProvider::new( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index e942de30b..9c8c40b08 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -116,6 +116,17 @@ pub trait IdentityProvider: Send + Sync { fn keeps_fresher_stored_uids(&self) -> bool { false } + + /// Whether a live UID the row already stores wins over a different one + /// in the provider's cookies. + /// + /// For a first-party ID that should stay stable for as long as the row + /// lives, such as Prebid's `SharedID`: when this returns `true`, a cookie + /// is taken only for a source with no live stored UID. A cookie opt-out + /// still wins. The default always takes the cookie. + fn keeps_stored_uids(&self) -> bool { + false + } } impl IdentityProvider for Box

{ @@ -155,6 +166,10 @@ impl IdentityProvider for Box

{ fn keeps_fresher_stored_uids(&self) -> bool { (**self).keeps_fresher_stored_uids() } + + fn keeps_stored_uids(&self) -> bool { + (**self).keeps_stored_uids() + } } /// What a provider found in the vendor's own first-party cookies. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index b528bffca..78b94f09c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -637,8 +637,11 @@ impl EcPartner { } /// One `[[ec.identity_providers]]` entry. +/// +/// A `sharedid` provider with no `sources` claims `pubcid.org`, the source +/// Prebid sends `SharedID` under. #[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(remote = "Self", deny_unknown_fields)] pub struct IdentityProviderConfig { /// Unique provider ID; recorded as the owner of the UIDs it writes. pub id: String, @@ -686,6 +689,28 @@ pub struct IdentityProviderConfig { pub settings: serde_json::Map, } +impl<'de> Deserialize<'de> for IdentityProviderConfig { + fn deserialize>(deserializer: D) -> Result { + let mut config = Self::deserialize(deserializer)?; + if config.kind == SHAREDID_PROVIDER_KIND && config.sources.is_empty() { + config.sources = vec![SHAREDID_DEFAULT_SOURCE.to_owned()]; + } + Ok(config) + } +} + +impl Serialize for IdentityProviderConfig { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} + +/// Provider kind of Prebid's `SharedID`. +const SHAREDID_PROVIDER_KIND: &str = "sharedid"; + +/// Source a `sharedid` provider claims when none is configured. +pub const SHAREDID_DEFAULT_SOURCE: &str = "pubcid.org"; + impl IdentityProviderConfig { const fn default_enabled() -> bool { true diff --git a/crates/trusted-server-identity-providers/src/lib.rs b/crates/trusted-server-identity-providers/src/lib.rs index c815ebaa2..28ebb4d4f 100644 --- a/crates/trusted-server-identity-providers/src/lib.rs +++ b/crates/trusted-server-identity-providers/src/lib.rs @@ -21,6 +21,7 @@ pub mod sharedid; pub mod uid2; pub mod unifiedid; +use std::borrow::Cow; use std::sync::Arc; use std::time::Duration; @@ -32,6 +33,7 @@ use trusted_server_core::ec::provider::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::integrations::prebid::{PrebidIntegrationConfig, PrebidUserIdStorageType}; use trusted_server_core::settings::{ CollectTiming, Ec, IdentityProviderConfig, ProviderSecrets, Settings, }; @@ -214,6 +216,57 @@ pub fn validate_providers(settings: &Settings) -> Result<(), Report Option { + let prebid = match settings.integration_config::("prebid") { + Ok(prebid) => prebid?, + Err(report) => { + log::warn!("Identity providers: Prebid config unreadable: {report}"); + return None; + } + }; + prebid + .managed_user_ids + .into_iter() + .filter(|entry| { + PREBID_SHAREDID_NAMES + .iter() + .any(|name| entry.name.eq_ignore_ascii_case(name)) + }) + .find_map(|entry| entry.storage) + .filter(|storage| storage.storage_type == PrebidUserIdStorageType::Cookie) + .map(|storage| storage.name) +} + +/// Fills a `sharedid` provider's `cookie_names` with the cookie Prebid's +/// `SharedID` module is configured to use, unless the provider sets its own. +fn with_prebid_sharedid_cookie<'config>( + config: &'config IdentityProviderConfig, + settings: &Settings, +) -> Cow<'config, IdentityProviderConfig> { + if config.kind() != sharedid::SharedId::KIND + || config.settings.contains_key(sharedid::COOKIE_NAMES_SETTING) + { + return Cow::Borrowed(config); + } + let Some(cookie) = prebid_sharedid_cookie(settings) else { + return Cow::Borrowed(config); + }; + let mut config = config.clone(); + config.settings.insert( + sharedid::COOKIE_NAMES_SETTING.to_owned(), + serde_json::Value::from(vec![cookie]), + ); + Cow::Owned(config) +} + fn build_phase_coordinator( settings: &Settings, partners: Arc, @@ -229,8 +282,9 @@ fn build_phase_coordinator( .iter() .filter(|config| config.enabled && config.collect == timing) .map(|config| { - build(config, &config.secrets) - .map(|provider| RegisteredProvider::new(config.clone(), provider)) + let config = with_prebid_sharedid_cookie(config, settings); + build(&config, &config.secrets) + .map(|provider| RegisteredProvider::new(config.into_owned(), provider)) }) .collect::, Report>>()?; if registered.is_empty() { @@ -425,6 +479,77 @@ mod tests { settings } + fn with_prebid_sharedid(settings: &mut Settings, storage: &serde_json::Value) { + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "bundle": { "modules": { "bidder": ["exampleBidderBidAdapter"] } }, + "managed_user_ids": [{ "name": "sharedId", "storage": storage }], + }), + ) + .expect("should configure Prebid"); + } + + #[test] + fn sharedid_reads_the_cookie_prebid_is_configured_to_use() { + // Arrange + let mut settings = settings_with_cookie_providers("page", "page"); + with_prebid_sharedid( + &mut settings, + &json!({ "type": "cookie", "name": "_pubcid" }), + ); + + // Act + let config = with_prebid_sharedid_cookie(&settings.ec.identity_providers[0], &settings); + + // Assert + assert_eq!( + config.settings.get("cookie_names"), + Some(&json!(["_pubcid"])), + "should read only Prebid's storage name" + ); + } + + #[test] + fn sharedid_keeps_its_defaults_without_a_prebid_cookie() { + let mut settings = settings_with_cookie_providers("page", "page"); + with_prebid_sharedid( + &mut settings, + &json!({ "type": "html5", "name": "_pubcid" }), + ); + + let config = with_prebid_sharedid_cookie(&settings.ec.identity_providers[0], &settings); + + assert!( + matches!(config, Cow::Borrowed(_)), + "should not take a local-storage key as a cookie name" + ); + } + + #[test] + fn sharedid_cookie_names_setting_wins_over_prebid() { + let mut settings = settings_with_cookie_providers("page", "page"); + with_prebid_sharedid( + &mut settings, + &json!({ "type": "cookie", "name": "_pubcid" }), + ); + settings.ec.identity_providers[0] + .settings + .insert("cookie_names".to_owned(), json!(["_example_sharedid"])); + + let config = with_prebid_sharedid_cookie(&settings.ec.identity_providers[0], &settings); + + assert_eq!( + config.settings.get("cookie_names"), + Some(&json!(["_example_sharedid"])), + "should keep the provider's own cookie names" + ); + } + fn liveramp_provider(collect: &str, settings: &str) -> IdentityProviderConfig { toml::from_str(&format!( "id = \"liveramp\"\nkind = \"liveramp\"\nsources = [\"liveramp.example.com\"]\n\ diff --git a/crates/trusted-server-identity-providers/src/sharedid.rs b/crates/trusted-server-identity-providers/src/sharedid.rs index d6a745bd9..bb0dc7ca7 100644 --- a/crates/trusted-server-identity-providers/src/sharedid.rs +++ b/crates/trusted-server-identity-providers/src/sharedid.rs @@ -1,19 +1,24 @@ //! Prebid `SharedID` identity provider: reads the publisher common ID from //! the first-party cookie Prebid's `sharedIdSystem` writes. //! -//! Prebid stores the ID as a bare UUID in `_sharedid` (the publisher's -//! configured name) or the legacy `_pubcid`. It is sent as a device-based -//! (agent type 1) EID. The opt-out cookie `_pubcid_optout` reports an -//! opt-out, which removes a stored `SharedID`. +//! Prebid stores the ID as a bare UUID in the cookie named by the +//! publisher's `storage.name`. The provider reads the cookie named in +//! `integrations.prebid.managed_user_ids[].storage.name` for the `sharedId` +//! (or `pubCommonId`) module; without one it reads `_sharedid`, then the +//! legacy `_pubcid`. It is sent as a device-based (agent type 1) EID under +//! `pubcid.org` unless another source is configured. The opt-out cookie +//! `_pubcid_optout` reports an opt-out, which removes a stored `SharedID`. //! No server call is made. //! +//! A stored `SharedID` lives as long as the row and wins over a different +//! cookie value, so the ID bidders see stays stable. +//! //! `SharedID` is a publisher first-party ID with no vendor server. With //! `generate = true`, when the cookies hold none and no live `SharedID` is //! stored, Trusted Server generates one itself: a random UUID v4, stored in -//! the row only. No cookie is set, so Prebid's own cookie is never touched; -//! when Prebid later writes one, the cookie's ID replaces the generated one. -//! An expired generated ID the row still holds is renewed with the same -//! value, so it stays stable for as long as the row lives. +//! the row only. No cookie is set, so Prebid's own cookie is never touched. +//! An expired generated ID an older row still holds is renewed with the same +//! value. //! Generation is off by default: Prebid may keep its opt-out in localStorage, //! which the server cannot see, so only a publisher who knows its opt-out //! lives in the `_pubcid_optout` cookie should turn it on. @@ -25,14 +30,15 @@ use serde::Deserialize; use trusted_server_core::ec::eid_types::SourceDomain; use trusted_server_core::ec::provider::{ CookieRead, IdentityProvider, LookupInput, LookupOutcome, ProviderCapabilities, ProviderCommon, - ProviderConfigError, ProviderEid, ProviderError, ProviderKind, ProviderRequest, + ProviderConfigError, ProviderEid, ProviderError, ProviderExpiry, ProviderKind, ProviderRequest, ProviderResponse, RequestCookies, }; use uuid::Uuid; -use crate::common::{ - CookieReader, MAX_TTL, bounded_expiry, cookie_capabilities, device_eid, is_usable_id, -}; +use crate::common::{CookieReader, cookie_capabilities, device_eid, is_usable_id}; + +/// Settings key naming the cookies that hold the `SharedID`. +pub const COOKIE_NAMES_SETTING: &str = "cookie_names"; /// Cookie Prebid sets when the user opts out of `SharedID`. const OPT_OUT_COOKIE: &str = "_pubcid_optout"; @@ -44,7 +50,8 @@ pub struct SharedId; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct SharedIdSettings { - /// Cookies holding the `SharedID`, read in order. + /// Cookies holding the `SharedID`, read in order. Defaults to Prebid's + /// configured `storage.name`, else `_sharedid` then `_pubcid`. #[serde(default = "SharedIdSettings::default_cookie_names")] pub cookie_names: Vec, /// Whether to generate a `SharedID` when the cookies hold none. Off by @@ -123,13 +130,18 @@ impl IdentityProvider for SharedIdProvider { else { return CookieRead::Absent; }; + // No expiry: the ID lives as long as the row. CookieRead::Found(vec![device_eid( &self.source, Cow::Borrowed(id), - bounded_expiry(None), + ProviderExpiry::Unspecified, )]) } + fn keeps_stored_uids(&self) -> bool { + true + } + fn generate(&self, lookup: &LookupInput<'_>) -> Option>> { if !self.generate { return None; @@ -147,9 +159,8 @@ impl IdentityProvider for SharedIdProvider { Some(vec![device_eid( &self.source, Cow::Owned(id), - // A generated ID has no cookie to renew it, so it keeps the - // longest lifetime rather than changing every day. - bounded_expiry(Some(MAX_TTL)), + // A generated ID lives as long as the row. + ProviderExpiry::Unspecified, )]) } } @@ -161,7 +172,7 @@ mod tests { AgentType, EidFields, Expiry, ProviderId, UidValue, UnixSeconds, }; use trusted_server_core::ec::kv_types::{KvEid, KvEntry, KvPartnerId, KvUid}; - use trusted_server_core::ec::provider::{OwnedEntries, ProviderExpiry, build_kind}; + use trusted_server_core::ec::provider::{OwnedEntries, build_kind}; use trusted_server_core::settings::{IdentityProviderConfig, ProviderSecrets}; use super::*; @@ -208,6 +219,43 @@ mod tests { ); } + #[test] + fn keeps_a_cookie_id_as_long_as_the_row_and_over_a_different_cookie() { + let provider = provider(); + let eids = provider + .read_request_cookies(RequestCookies::new("_sharedid=sharedid-example")) + .into_found() + .expect("should read the cookie"); + + assert!( + matches!(eids[0].uids[0].expiry, ProviderExpiry::Unspecified), + "should state no expiry, so the ID lives as long as the row" + ); + assert!( + provider.keeps_stored_uids(), + "should keep a stored ID over a different cookie" + ); + } + + #[test] + fn defaults_the_source_to_pubcid_org() { + let config: IdentityProviderConfig = serde_json::from_value(json!({ + "id": "sharedid", + "kind": "sharedid", + })) + .expect("should parse provider config"); + + let provider = build_kind::(&config, &ProviderSecrets::default()) + .expect("should build provider"); + let eids = provider + .read_request_cookies(RequestCookies::new("_sharedid=sharedid-example")) + .into_found() + .expect("should read the cookie"); + + assert_eq!(eids[0].source, "pubcid.org", "should default to pubcid.org"); + assert_eq!(eids[0].uids[0].atype, Some(1), "should be device-based"); + } + #[test] fn honours_opt_out_and_ignores_blank_ids() { assert!( @@ -314,8 +362,8 @@ mod tests { ); assert_ne!(uid.id, second[0].uids[0].id, "should be random"); assert!( - matches!(uid.expiry, ProviderExpiry::After(ttl) if ttl == MAX_TTL), - "should keep a generated ID for the longest TTL" + matches!(uid.expiry, ProviderExpiry::Unspecified), + "should keep a generated ID as long as the row" ); } } diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 81c2cb939..92033001b 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -295,21 +295,23 @@ pull_sync_concurrency = 3 # cookie_names = ["example_lockr_uid2"] # # Prebid SharedID (kind "sharedid"): no lookup and no endpoint. Reads the -# publisher common ID from Prebid's first-party `_sharedid` cookie, falling -# back to the legacy `_pubcid`, as a device-based (atype 1) ID. The -# `_pubcid_optout` cookie suppresses it. Takes exactly one source. With -# generate = true, when no cookie holds an ID and none is stored, Trusted Server -# generates a random UUID and stores it in the row only (no cookie is set); a -# later Prebid cookie replaces it. Leave it off unless Prebid keeps its opt-out -# in the `_pubcid_optout` cookie: a localStorage opt-out is invisible to the -# server. SharedID is the publisher's own ID, so it has no TCF vendor. +# publisher common ID from the cookie named in +# [[integrations.prebid.managed_user_ids]] `storage.name` for the `sharedId` +# module, or else from `_sharedid`, falling back to the legacy `_pubcid`, and +# sends it as a device-based (atype 1) ID. `sources` defaults to +# ["pubcid.org"], which needs a matching [[ec.partners]] entry. A stored +# SharedID lives as long as the row and wins over a different cookie value. +# The `_pubcid_optout` cookie suppresses it. With generate = true, when no +# cookie holds an ID and none is stored, Trusted Server generates a random UUID +# and stores it in the row only (no cookie is set). Leave it off unless Prebid +# keeps its opt-out in the `_pubcid_optout` cookie: a localStorage opt-out is +# invisible to the server. SharedID is the publisher's own ID, so it has no TCF +# vendor. # [[ec.identity_providers]] # id = "sharedid" # kind = "sharedid" # enabled = false -# sources = ["sharedid.example.com"] # [ec.identity_providers.settings] -# cookie_names = ["_sharedid", "_pubcid"] # generate = false # # Criteo (kind "criteo"): no lookup and no endpoint. Reads Criteo's bid ID From 0c13d427245e6f1097ce48a60a13d6ce6004933a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 12 Nov 2026 19:33:38 +0530 Subject: [PATCH 2/4] Check registered provider kinds at deploy instead of rejecting every provider Core cannot see the provider crate's kinds, so its deploy validation refused any enabled identity provider. ts config already builds every enabled provider, which refuses an unregistered kind; core now leaves that check to it. Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/config.rs | 33 +++---------------- .../src/lib.rs | 17 ++++++++++ 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index f729a22f2..de2f152d0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -344,30 +344,12 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report Result<(), Report> { - match ec.identity_providers.iter().find(|config| config.enabled) { - Some(config) => Err(Report::new(TrustedServerError::Configuration { - message: format!( - "ec.identity_providers: provider `{}` is enabled, but this release registers no \ - provider kinds; set `enabled = false`", - config.id - ), - })), - None => Ok(()), - } -} - /// Runs Trusted Server runtime validation after secret references are resolved. /// /// # Errors @@ -1630,16 +1612,11 @@ gam_network_id = "99999" } #[test] - fn deploy_validation_rejects_an_enabled_provider_while_no_kind_is_registered() { + fn deploy_validation_accepts_an_enabled_provider() { let settings = settings_with_identity_provider(serde_json::json!({})); - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject an enabled provider at deploy time"); - - assert!( - err.to_string().contains("registers no provider kinds"), - "should explain why the provider is rejected: {err:?}" - ); + validate_settings_for_deploy(&settings) + .expect("should leave the registered-kind check to the provider crate"); } #[test] diff --git a/crates/trusted-server-identity-providers/src/lib.rs b/crates/trusted-server-identity-providers/src/lib.rs index 28ebb4d4f..1a2abc79a 100644 --- a/crates/trusted-server-identity-providers/src/lib.rs +++ b/crates/trusted-server-identity-providers/src/lib.rs @@ -602,6 +602,23 @@ mod tests { } } + #[test] + fn validate_providers_rejects_an_enabled_unregistered_kind() { + let mut settings = settings_with_cookie_providers("page", "page"); + settings.ec.identity_providers.push( + toml::from_str("id = \"missing\"\nkind = \"missing\"\nsources = [\"criteo.com\"]") + .expect("should parse provider"), + ); + + let error = validate_providers(&settings) + .expect_err("should refuse an enabled provider whose kind is not registered"); + + assert!( + format!("{error:?}").contains("which this build does not include"), + "should name the unregistered kind: {error:?}" + ); + } + #[test] fn validate_providers_accepts_partner_tokens_that_are_still_key_names() { let mut settings = settings_with_cookie_providers("page", "page"); From 990982dd322cf045153dafd069e07aa7b03caf82 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 12 Nov 2026 19:58:32 +0530 Subject: [PATCH 3/4] Run server-side identity providers on the Axum dev server Axum now builds the partner registry and both provider coordinators at startup, reads and mints ts-ec on publisher navigations, runs page-phase lookups through Axum's HTTP client, and finalizes through KvIdentityGraph with a single page-load write. After-response providers run on a spawned blocking task with their follow-up compare-and-swap write. The identity graph is a process-local store with generation-based CAS, enabled when ec.ec_store is set. Signed-off-by: dhruv8sh --- Cargo.lock | 2 + crates/trusted-server-adapter-axum/Cargo.toml | 2 + crates/trusted-server-adapter-axum/src/app.rs | 213 ++++++-- .../trusted-server-adapter-axum/src/ec_kv.rs | 352 ++++++++++++ .../src/identity.rs | 334 ++++++++++++ .../src/identity_tests.rs | 508 ++++++++++++++++++ crates/trusted-server-adapter-axum/src/lib.rs | 6 + 7 files changed, 1383 insertions(+), 34 deletions(-) create mode 100644 crates/trusted-server-adapter-axum/src/ec_kv.rs create mode 100644 crates/trusted-server-adapter-axum/src/identity.rs create mode 100644 crates/trusted-server-adapter-axum/src/identity_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 66e402766..17eb7250d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6611,11 +6611,13 @@ dependencies = [ "futures", "log", "reqwest 0.12.28", + "serde_json", "simple_logger", "temp-env", "tokio", "tower 0.4.13", "trusted-server-core", + "trusted-server-identity-providers", "web-time", ] diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index af707aa24..0d9ea7247 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,12 +29,14 @@ reqwest = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } +trusted-server-identity-providers = { workspace = true } web-time = { workspace = true } [dev-dependencies] trusted-server-core = { workspace = true, features = ["test-utils"] } axum = { workspace = true } base64 = { workspace = true } +serde_json = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tower = { workspace = true, features = ["util"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 585a9d24f..5b70ea906 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -9,18 +9,22 @@ use edgezero_core::http::{ HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, header, }; use edgezero_core::router::RouterService; -use error_stack::Report; +use error_stack::{Report, ResultExt as _}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; -use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::coordinator::ProviderCoordinator; +use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::page_load::NavigationPermit; use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -40,7 +44,15 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::{ClientInfo, RuntimeServices}; use trusted_server_core::trace::{TraceMetadata, TracePreDispatchHook}; +use trusted_server_identity_providers::{ + build_after_response_coordinator, build_coordinator_with, partner_registry, +}; +use crate::ec_kv::{LocalEcKvStore, LocalRateLimiter}; +use crate::identity::{ + build_ec_request_state, device_signals_for, dispatch_page_lookups, finalize_navigation, + looks_like_browser, prepare_after_response_lookups, spawn_after_response_lookups, +}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; @@ -54,14 +66,24 @@ pub struct AppState { orchestrator: Arc, registry: Arc, services: Option, + /// Partner registry, with the sources of enabled providers marked. + partner_registry: Arc, + /// Coordinator for providers collected before the response, if any. + page_identity: Option>, + /// Coordinator for providers collected after the response, if any. + after_response_identity: Option>, + /// Process-local EC identity store, present when `ec.ec_store` is set. + ec_store: Option, + rate_limiter: LocalRateLimiter, } /// Build the application state, loading settings and constructing all per-application components. /// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when settings, the auction orchestrator, the integration +/// registry, the partner registry or an enabled identity provider fail to +/// initialise. fn build_state() -> Result, Report> { let store_name = default_config_store_name(); let config_key = default_config_key(); @@ -87,25 +109,89 @@ fn build_state_with_settings( build_state_with_services(settings, None) } -fn build_state_with_services( +pub(crate) fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { - trusted_server_core::ec::provider::reject_enabled_providers(&settings.ec, "Axum")?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + // A provider that cannot run must stop startup: skipping it would quietly + // hand its sources back to browser ingest. + let partner_registry = Arc::new(partner_registry(&settings.ec)?); + let page_identity = build_coordinator_with(&settings, Arc::clone(&partner_registry)) + .change_context(TrustedServerError::Configuration { + message: "identity provider configuration is invalid".to_owned(), + })? + .map(Arc::new); + let after_response_identity = + build_after_response_coordinator(&settings, Arc::clone(&partner_registry)) + .change_context(TrustedServerError::Configuration { + message: "identity provider configuration is invalid".to_owned(), + })? + .map(Arc::new); + let ec_store = settings.ec.ec_store.as_deref().map(|store_name| { + log::warn!( + "Axum dev server: EC identity store `{store_name}` is process-local and is lost on exit" + ); + LocalEcKvStore::new(store_name) + }); + Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), services, + partner_registry, + page_identity, + after_response_identity, + ec_store, + rate_limiter: LocalRateLimiter::default(), })) } impl AppState { + pub(crate) fn settings(&self) -> &Settings { + &self.settings + } + + pub(crate) fn partner_registry(&self) -> &PartnerRegistry { + &self.partner_registry + } + + pub(crate) fn page_identity(&self) -> Option<&Arc> { + self.page_identity.as_ref() + } + + pub(crate) fn after_response_identity(&self) -> Option<&Arc> { + self.after_response_identity.as_ref() + } + + pub(crate) fn rate_limiter(&self) -> &LocalRateLimiter { + &self.rate_limiter + } + + /// Builds an identity graph over the shared process-local store. + pub(crate) fn identity_graph(&self) -> Option { + self.ec_store.clone().map(KvIdentityGraph::new) + } + + /// Identity graph for read-only auction enrichment from a browser. + fn auction_identity_graph(&self, req: &Request) -> Option { + if looks_like_browser(&device_signals_for(req)) { + self.identity_graph() + } else { + None + } + } + + /// Partner registry for auction EID enrichment, `None` when empty. + fn auction_partner_registry(&self) -> Option<&PartnerRegistry> { + (!self.partner_registry.is_empty()).then_some(&*self.partner_registry) + } + fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() @@ -208,7 +294,7 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) // --------------------------------------------------------------------------- async fn dispatch_fallback( - state: &AppState, + state: &Arc, services: &RuntimeServices, mut req: Request, ) -> Result> { @@ -242,36 +328,93 @@ async fn dispatch_fallback( }); } + let mut ec = build_ec_request_state(state, services, &req); + + // Mint and look up only on document navigations by recognised browsers; + // subresource requests may lack consent signals such as Sec-GPC. + let is_navigation = is_navigation_request(&req); + let mut has_permit = false; + if is_navigation { + ec.ec_context.set_eid_sync_source(EidSyncSource::Navigation); + // The page-load write permit uses the stricter Fetch Metadata test; + // requests without it never write KV. + if let Some(permit) = NavigationPermit::classify(&req) { + ec.ec_context.set_navigation_permit(permit); + has_permit = true; + } + } + let is_publisher_navigation = ec.is_real_browser && is_navigation && has_permit; + // A new EC is minted only when this request may also write its row. + if is_publisher_navigation + && let Err(err) = ec + .ec_context + .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) + { + log::warn!("EC generation failed for publisher proxy: {err:?}"); + } + + // Page-phase lookups start before the origin fetch so they overlap it. + let page_lookups = if is_publisher_navigation { + dispatch_page_lookups(state, services, &req, &mut ec).await + } else { + None + }; + let after_response = if is_publisher_navigation { + prepare_after_response_lookups(state, services, &req, &ec) + } else { + None + }; + // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); let auction = AuctionDispatch { integration_registry: &state.registry, orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), - registry: None, + registry: state.auction_partner_registry(), }; - let publisher_response = handle_publisher_request( + let result = match handle_publisher_request( &state.settings, services, - None, - &mut ec_context, + ec.kv_graph.as_ref(), + &mut ec.ec_context, auction, req, EdgeCacheHeader::SMaxageFallback, ) - .await?; - // Async finalize so the dispatched auction is collected and its bids are - // injected before `` (the sync buffer path would drop them). - buffer_publisher_response_async( - publisher_response, - &method, - &state.settings, - &state.registry, - &state.orchestrator, - services, - ) .await + { + Ok(publisher_response) => { + // Origin start succeeded: authorize orphan recovery only for + // real-browser document navigations. + ec.ec_context + .set_recovery_eligible(ec.is_real_browser && is_navigation); + // Async finalize so the dispatched auction is collected and its + // bids are injected before `` (the sync buffer path would + // drop them). + buffer_publisher_response_async( + publisher_response, + &method, + &state.settings, + &state.registry, + &state.orchestrator, + services, + ) + .await + } + Err(report) => Err(report), + }; + + let mut response = result.unwrap_or_else(|report| http_error(&report)); + finalize_navigation(state, services, &mut ec, page_lookups, &mut response).await; + // The task runs to completion on its own; its handle is not awaited. + drop(spawn_after_response_lookups( + Arc::clone(state), + services.clone(), + &mut ec, + after_response, + )); + Ok(response) } fn fallback_handler( @@ -497,8 +640,7 @@ fn named_route_handler( Ok(admin_ec_lookup_not_supported()) } NamedRouteHandler::AdminEidsLookup => { - let partner_registry = PartnerRegistry::from_ec(&state.settings.ec)?; - handle_admin_eids_lookup(&partner_registry, &req) + handle_admin_eids_lookup(&state.partner_registry, &req) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), NamedRouteHandler::Auction => { @@ -506,11 +648,13 @@ fn named_route_handler( // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. let mut ec_context = build_ec_context(&state, &services, &req); + ec_context.set_eid_sync_source(EidSyncSource::Auction); + let kv_graph = state.auction_identity_graph(&req); handle_auction( &state.settings, &state.orchestrator, - None, - None, + kv_graph.as_ref(), + state.auction_partner_registry(), &mut ec_context, &services, req, @@ -529,12 +673,13 @@ fn named_route_handler( integration_registry: &state.registry, orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), - registry: None, + registry: state.auction_partner_registry(), }; + let kv_graph = state.auction_identity_graph(&req); handle_page_bids( &state.settings, &services, - None, + kv_graph.as_ref(), auction, &mut ec_context, req, @@ -672,7 +817,7 @@ impl TrustedServerApp { } } -fn build_router(state: &Arc) -> RouterService { +pub(crate) fn build_router(state: &Arc) -> RouterService { let fallback = fallback_handler(Arc::clone(state)); let trace_state = Arc::clone(state); @@ -754,19 +899,19 @@ mod trace_dispatch_tests { use trusted_server_core::trace::TraceTerminalResponse; #[test] - fn enabled_identity_provider_fails_startup() { + fn unregistered_identity_provider_kind_fails_startup() { let mut settings = trace_settings(false); settings.ec.identity_providers = vec![trusted_server_core::test_support::fixtures::enabled_identity_provider()]; let Err(error) = build_state_with_settings(settings) else { - panic!("should refuse to start with an enabled identity provider"); + panic!("should refuse to start with an unregistered provider kind"); }; let message = format!("{error:?}"); assert!( - message.contains("example-provider") && message.contains("Axum"), - "should name the provider and the adapter: {message}" + message.contains("example-provider") && message.contains("example-kind"), + "should name the provider and its kind: {message}" ); } diff --git a/crates/trusted-server-adapter-axum/src/ec_kv.rs b/crates/trusted-server-adapter-axum/src/ec_kv.rs new file mode 100644 index 000000000..efe433b12 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/ec_kv.rs @@ -0,0 +1,352 @@ +//! Process-local EC identity store and rate limiter for the Axum dev server. +//! +//! The Axum dev server has no platform KV store, so the EC identity graph is +//! kept in process memory. Every operation takes one mutex, which makes reads +//! strongly consistent and gives the generation-based compare-and-swap that +//! [`EcKvStore`] requires. Data is lost when the process exits, so this store +//! is only for local development and tests. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use error_stack::Report; +use trusted_server_core::ec::kv_backend::{ + EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome, +}; +use trusted_server_core::ec::rate_limiter::RateLimiter; +use trusted_server_core::error::TrustedServerError; +use web_time::Instant; + +/// One stored row. +struct StoredEntry { + body: Vec, + metadata: Vec, + generation: u64, + expires_at: Option, +} + +impl StoredEntry { + fn is_live(&self, now: Instant) -> bool { + self.expires_at.is_none_or(|expires_at| now < expires_at) + } +} + +#[derive(Default)] +struct StoreState { + entries: BTreeMap, + /// Last generation handed out. Generations are never reused, even after + /// a delete, so a stale compare-and-swap marker can never match a newer + /// row for the same key. + last_generation: u64, +} + +impl StoreState { + /// Drops `key` when its TTL has passed, so reads never see expired rows. + fn purge_if_expired(&mut self, key: &str, now: Instant) { + if self + .entries + .get(key) + .is_some_and(|stored| !stored.is_live(now)) + { + self.entries.remove(key); + } + } +} + +/// Process-local [`EcKvStore`] for the Axum dev server. +/// +/// Cloning is cheap and every clone shares the same rows, so one store can +/// back the identity graph built for each request. +#[derive(Clone)] +pub struct LocalEcKvStore { + name: Arc, + state: Arc>, +} + +impl LocalEcKvStore { + /// Creates an empty store reported under `name` in logs and errors. + #[must_use] + pub fn new(name: &str) -> Self { + Self { + name: Arc::from(name), + state: Arc::new(Mutex::new(StoreState::default())), + } + } + + fn lock(&self) -> Result, Report> { + self.state.lock().map_err(|_| { + Report::new(TrustedServerError::KvStore { + store_name: self.name.to_string(), + message: "local EC store lock is poisoned".to_owned(), + }) + }) + } +} + +impl EcKvStore for LocalEcKvStore { + fn store_name(&self) -> &str { + &self.name + } + + fn lookup(&self, key: &str) -> Result, Report> { + let mut state = self.lock()?; + state.purge_if_expired(key, Instant::now()); + Ok(state.entries.get(key).map(|stored| EcKvLookup { + body: stored.body.clone(), + metadata: Some(stored.metadata.clone()), + generation: stored.generation, + })) + } + + fn key_exists(&self, key: &str) -> Result> { + // Reads and writes share one lock, so this answer is strongly + // consistent with every completed write. + let mut state = self.lock()?; + state.purge_if_expired(key, Instant::now()); + Ok(state.entries.contains_key(key)) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + let now = Instant::now(); + let mut state = self.lock()?; + state.purge_if_expired(key, now); + let existing_generation = state.entries.get(key).map(|stored| stored.generation); + + let precondition_holds = match write.mode { + EcKvWriteMode::Add => existing_generation.is_none(), + EcKvWriteMode::Overwrite => true, + EcKvWriteMode::IfGenerationMatch(expected) => existing_generation == Some(expected), + }; + if !precondition_holds { + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + + state.last_generation += 1; + let generation = state.last_generation; + state.entries.insert( + key.to_owned(), + StoredEntry { + body: write.body.as_bytes().to_vec(), + metadata: write.metadata.as_bytes().to_vec(), + generation, + expires_at: now.checked_add(write.ttl), + }, + ); + Ok(EcKvWriteOutcome::Written) + } + + fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + let now = Instant::now(); + let state = self.lock()?; + Ok(state + .entries + .range(prefix.to_owned()..) + .take_while(|(key, _)| key.starts_with(prefix)) + .filter(|(_, stored)| stored.is_live(now)) + .take(usize::try_from(limit).unwrap_or(usize::MAX)) + .map(|(key, _)| key.clone()) + .collect()) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + self.lock()?.entries.remove(key); + Ok(()) + } +} + +/// Length of one rate-limit window. +const RATE_WINDOW: Duration = Duration::from_secs(60); + +/// Process-local fixed-window [`RateLimiter`] for the Axum dev server. +/// +/// Mirrors the Fastly limiter: an hourly budget is enforced as a per-minute +/// budget rounded up to at least one request. +#[derive(Clone, Default)] +pub struct LocalRateLimiter { + windows: Arc>>, +} + +impl RateLimiter for LocalRateLimiter { + fn exceeded(&self, key: &str, hourly_limit: u32) -> Result> { + if hourly_limit == 0 { + return Ok(true); + } + let per_minute_limit = (hourly_limit.saturating_add(59) / 60).max(1); + let now = Instant::now(); + let mut windows = self.windows.lock().map_err(|_| { + Report::new(TrustedServerError::KvStore { + store_name: "local-rate-limiter".to_owned(), + message: "local rate limiter lock is poisoned".to_owned(), + }) + })?; + let window = windows.entry(key.to_owned()).or_insert((now, 0)); + if now.duration_since(window.0) >= RATE_WINDOW { + *window = (now, 0); + } + if window.1 >= per_minute_limit { + return Ok(true); + } + window.1 += 1; + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(body: &str, mode: EcKvWriteMode) -> EcKvWrite<'_> { + EcKvWrite { + body, + metadata: "{}", + ttl: Duration::from_secs(60), + mode, + } + } + + #[test] + fn add_fails_when_the_key_exists() { + let store = LocalEcKvStore::new("example-store"); + + let first = store + .insert("key", write("a", EcKvWriteMode::Add)) + .expect("should insert"); + let second = store + .insert("key", write("b", EcKvWriteMode::Add)) + .expect("should report the precondition"); + + assert_eq!(first, EcKvWriteOutcome::Written, "should create the key"); + assert_eq!( + second, + EcKvWriteOutcome::PreconditionFailed, + "should refuse to add an existing key" + ); + } + + #[test] + fn generation_match_applies_only_to_the_current_generation() { + let store = LocalEcKvStore::new("example-store"); + store + .insert("key", write("a", EcKvWriteMode::Add)) + .expect("should insert"); + let generation = store + .lookup("key") + .expect("should read") + .expect("should find the key") + .generation; + + let applied = store + .insert( + "key", + write("b", EcKvWriteMode::IfGenerationMatch(generation)), + ) + .expect("should write"); + let stale = store + .insert( + "key", + write("c", EcKvWriteMode::IfGenerationMatch(generation)), + ) + .expect("should report the precondition"); + + assert_eq!(applied, EcKvWriteOutcome::Written, "should apply the CAS"); + assert_eq!( + stale, + EcKvWriteOutcome::PreconditionFailed, + "should reject a stale generation" + ); + let body = store + .lookup("key") + .expect("should read") + .expect("should find the key") + .body; + assert_eq!(body, b"b", "should keep the CAS winner"); + } + + #[test] + fn generations_are_not_reused_after_delete() { + let store = LocalEcKvStore::new("example-store"); + store + .insert("key", write("a", EcKvWriteMode::Add)) + .expect("should insert"); + let old = store + .lookup("key") + .expect("should read") + .expect("should find the key") + .generation; + store.delete("key").expect("should delete"); + store + .insert("key", write("b", EcKvWriteMode::Add)) + .expect("should insert again"); + + let outcome = store + .insert("key", write("c", EcKvWriteMode::IfGenerationMatch(old))) + .expect("should report the precondition"); + + assert_eq!( + outcome, + EcKvWriteOutcome::PreconditionFailed, + "should not match a generation from a deleted row" + ); + } + + #[test] + fn expired_rows_are_absent() { + let store = LocalEcKvStore::new("example-store"); + store + .insert( + "key", + EcKvWrite { + body: "a", + metadata: "{}", + ttl: Duration::ZERO, + mode: EcKvWriteMode::Add, + }, + ) + .expect("should insert"); + + assert!( + !store.key_exists("key").expect("should check"), + "should treat an expired row as absent" + ); + assert!( + store.lookup("key").expect("should read").is_none(), + "should not return an expired row" + ); + } + + #[test] + fn prefix_listing_is_bounded() { + let store = LocalEcKvStore::new("example-store"); + for key in ["a.1", "a.2", "a.3", "b.1"] { + store + .insert(key, write("x", EcKvWriteMode::Overwrite)) + .expect("should insert"); + } + + let keys = store + .list_keys_with_prefix("a.", 2) + .expect("should list keys"); + + assert_eq!(keys, vec!["a.1", "a.2"], "should return one bounded page"); + } + + #[test] + fn rate_limiter_enforces_the_per_minute_budget() { + let limiter = LocalRateLimiter::default(); + + let first = limiter.exceeded("key", 60).expect("should check"); + let second = limiter.exceeded("key", 60).expect("should check"); + + assert!(!first, "should allow the first request"); + assert!(second, "should limit the second request in the window"); + } +} diff --git a/crates/trusted-server-adapter-axum/src/identity.rs b/crates/trusted-server-adapter-axum/src/identity.rs new file mode 100644 index 000000000..a3e73ce8e --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/identity.rs @@ -0,0 +1,334 @@ +//! EC identity lifecycle for publisher navigations on the Axum dev server. +//! +//! Mirrors the Fastly adapter: the `ts-ec` cookie is read and minted, enabled +//! identity providers run on permitted navigations, the page-phase results +//! go into the single page-load write in EC finalization, and after-response +//! providers run once the response has been produced, with their own +//! follow-up compare-and-swap write. +//! +//! Differences from Fastly, all specific to the local dev server: +//! +//! - The identity graph is the process-local [`crate::ec_kv::LocalEcKvStore`]. +//! - The dev server sees no TLS or HTTP/2 fingerprint, so the bot gate +//! accepts any request whose `User-Agent` names a known platform. +//! - After-response lookups run on a blocking tokio task spawned when the +//! handler returns, rather than after the response bytes are sent. +//! - Partner pull sync is not run. + +use std::net::IpAddr; +use std::sync::Arc; + +use edgezero_core::http::{Request, Response, header}; +use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; +use trusted_server_core::cookies::extract_cookie_value; +use trusted_server_core::ec::after_response::{ + after_response_lookups_allowed, commit_after_response_lookups, +}; +use trusted_server_core::ec::consent::ec_consent_withdrawn; +use trusted_server_core::ec::coordinator::transport::PlatformLookupTransport; +use trusted_server_core::ec::coordinator::{ + IdentityCoordinator as _, LookupContext, PendingLookups, ProviderCoordinator, +}; +use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::eid_types::UnixSeconds; +use trusted_server_core::ec::finalize::ec_finalize_response; +use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::page_load::NavigationPermit; +use trusted_server_core::ec::provider::{ClientSignals, RequestCookies}; +use trusted_server_core::ec::{EcContext, EcKvSnapshot, current_timestamp}; +use trusted_server_core::platform::{PlatformPendingRequest, RuntimeServices}; + +use crate::app::AppState; + +/// Per-request EC state for a publisher navigation. +pub(crate) struct EcRequestState { + pub(crate) ec_context: EcContext, + /// Graph for reads and the auction; `None` for a non-browser. + pub(crate) kv_graph: Option, + /// Graph for the finalize write; kept for a consent withdrawal even from + /// a non-browser so tombstones stay authoritative. + finalize_kv_graph: Option, + eids_cookie: Option, + sharedid_cookie: Option, + pub(crate) is_real_browser: bool, +} + +/// Identity lookups dispatched for a navigation, collected before finalize. +pub(crate) struct PageLookups { + coordinator: Arc, + pending: PendingLookups, +} + +/// Request signals captured for after-response lookups. +pub(crate) struct AfterResponseLookups { + ec_id: String, + client_ip: Option, + user_agent: Option, + cookie_header: String, +} + +/// Joins every `Cookie` header on the request into one `; `-separated value. +fn joined_cookie_header(req: &Request) -> String { + req.headers() + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>() + .join("; ") +} + +fn user_agent(req: &Request) -> Option<&str> { + req.headers() + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) +} + +/// Device signals for a request; the dev server only has the `User-Agent`. +pub(crate) fn device_signals_for(req: &Request) -> DeviceSignals { + DeviceSignals::derive(user_agent(req).unwrap_or(""), None, None) +} + +/// Dev-server bot gate. +/// +/// Fastly also requires a TLS fingerprint, which a plain-HTTP local server +/// never has, so only the `User-Agent` platform is checked here. +pub(crate) fn looks_like_browser(signals: &DeviceSignals) -> bool { + signals.platform_class.is_some() +} + +/// Builds the per-request EC state, mirroring the Fastly pre-routing prelude. +pub(crate) fn build_ec_request_state( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> EcRequestState { + let device_signals = device_signals_for(req); + let is_real_browser = looks_like_browser(&device_signals); + if !is_real_browser { + log::info!("Bot gate: blocking EC operations for a request without a known UA platform"); + } + + let eids_cookie = extract_cookie_value(req, COOKIE_TS_EIDS); + let sharedid_cookie = extract_cookie_value(req, COOKIE_SHAREDID); + + let geo_info = services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed during EC setup: {e}"); + None + }); + let (ec_context, setup_ok) = match EcContext::read_from_request_with_geo( + state.settings(), + req, + services, + geo_info.as_ref(), + ) { + Ok(mut context) => { + context.set_device_signals(device_signals); + (context, true) + } + Err(report) => { + // A malformed consent signal leaves a fail-closed default context + // with no identity-graph access. + log::warn!("EC context read failed: {report:?}"); + (EcContext::default(), false) + } + }; + + let kv_graph = setup_ok.then(|| state.identity_graph()).flatten(); + let finalize_kv_graph = if is_real_browser || ec_consent_withdrawn(ec_context.consent()) { + kv_graph.clone() + } else { + None + }; + let kv_graph = if is_real_browser { kv_graph } else { None }; + + EcRequestState { + ec_context, + kv_graph, + finalize_kv_graph, + eids_cookie, + sharedid_cookie, + is_real_browser, + } +} + +/// Starts page-phase identity lookups for a permitted publisher navigation. +/// +/// Returns `None` when no page-phase provider is enabled, the identity graph +/// is unavailable, or the EC was minted on this request (it has no row yet, +/// so a later navigation looks it up). +pub(crate) async fn dispatch_page_lookups( + state: &AppState, + services: &RuntimeServices, + req: &Request, + ec: &mut EcRequestState, +) -> Option { + let coordinator = Arc::clone(state.page_identity()?); + let graph = ec.kv_graph.as_ref()?; + if ec.ec_context.ec_generated() { + return None; + } + let ec_id = ec.ec_context.ec_value()?.to_owned(); + if matches!(ec.ec_context.kv_snapshot(), EcKvSnapshot::NotRead) { + let snapshot = graph.load_snapshot(&ec_id); + ec.ec_context.set_kv_snapshot(snapshot); + } + let transport = PlatformLookupTransport::new(services); + let cookie_header = joined_cookie_header(req); + let pending = coordinator + .dispatch( + &transport, + &LookupContext { + ec_id: &ec_id, + snapshot: ec.ec_context.kv_snapshot(), + consent: ec.ec_context.consent(), + client: ClientSignals { + ip: services.client_info().client_ip, + user_agent: user_agent(req), + }, + publisher_domain: &state.settings().publisher.domain, + cookies: RequestCookies::new(&cookie_header), + rate_limiter: state.rate_limiter(), + now: UnixSeconds::new(current_timestamp()), + }, + ) + .await; + Some(PageLookups { + coordinator, + pending, + }) +} + +/// Captures request signals for after-response lookups. +/// +/// Returns `None` when no after-response provider is enabled, the request +/// has no EC, or the identity graph is unavailable. +pub(crate) fn prepare_after_response_lookups( + state: &AppState, + services: &RuntimeServices, + req: &Request, + ec: &EcRequestState, +) -> Option { + state.after_response_identity()?; + ec.kv_graph.as_ref()?; + let ec_id = ec.ec_context.ec_value()?.to_owned(); + Some(AfterResponseLookups { + ec_id, + client_ip: services.client_info().client_ip, + user_agent: user_agent(req).map(str::to_owned), + cookie_header: joined_cookie_header(req), + }) +} + +/// Collects page-phase lookups and runs EC finalization on `response`. +/// +/// Finalization performs at most one page-load write to the identity graph. +pub(crate) async fn finalize_navigation( + state: &AppState, + services: &RuntimeServices, + ec: &mut EcRequestState, + page_lookups: Option, + response: &mut Response, +) { + // A redirect skips the write, so do not wait for lookups it would discard. + if let Some(PageLookups { + coordinator, + pending, + }) = page_lookups + { + if NavigationPermit::is_redirect(response.status()) { + log::debug!("Identity lookups dropped: redirect response skips the write"); + } else { + let transport = PlatformLookupTransport::new(services); + let results = coordinator.collect(&transport, pending).await; + ec.ec_context.set_lookup_results(results); + } + } + ec_finalize_response( + state.settings(), + &mut ec.ec_context, + ec.finalize_kv_graph.as_ref(), + state.partner_registry(), + ec.eids_cookie.as_deref(), + ec.sharedid_cookie.as_deref(), + response, + ); +} + +/// Spawns after-response lookups once the navigation response is ready. +/// +/// Runs only when finalization issued the follow-up permit. The lookups run +/// on a blocking tokio task so they never delay the response; returns `None` +/// when nothing was spawned. +pub(crate) fn spawn_after_response_lookups( + state: Arc, + services: RuntimeServices, + ec: &mut EcRequestState, + lookups: Option, +) -> Option> { + let lookups = lookups?; + let Some(permit) = ec.ec_context.take_after_response_permit() else { + log::debug!( + "After-response lookups skipped: no permit (consent, redirect or failed page-load write)" + ); + return None; + }; + if ec.ec_context.ec_value() != Some(lookups.ec_id.as_str()) { + log::debug!("After-response lookups skipped: EC changed during the request"); + return None; + } + let ec_generated = ec.ec_context.ec_generated(); + let consent = ec.ec_context.consent().clone(); + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + log::warn!("After-response lookups skipped: no tokio runtime"); + return None; + }; + Some(tokio::task::spawn_blocking(move || { + runtime.block_on(async move { + let Some(coordinator) = state.after_response_identity() else { + return; + }; + let Some(graph) = state.identity_graph() else { + return; + }; + let snapshot = graph.load_snapshot(&lookups.ec_id); + if !after_response_lookups_allowed(&snapshot, &lookups.ec_id, ec_generated) { + log::debug!("After-response lookups skipped: new EC or no live row"); + return; + } + let transport = PlatformLookupTransport::new(&services); + let pending = coordinator + .dispatch( + &transport, + &LookupContext { + ec_id: &lookups.ec_id, + snapshot: &snapshot, + consent: &consent, + client: ClientSignals { + ip: lookups.client_ip, + user_agent: lookups.user_agent.as_deref(), + }, + publisher_domain: &state.settings().publisher.domain, + cookies: RequestCookies::new(&lookups.cookie_header), + rate_limiter: state.rate_limiter(), + now: UnixSeconds::new(current_timestamp()), + }, + ) + .await; + if pending.is_empty() { + return; + } + let results = coordinator.collect(&transport, pending).await; + let outcome = commit_after_response_lookups( + &graph, + permit, + state.partner_registry(), + &lookups.ec_id, + &results, + ); + log::info!("After-response lookups: write outcome={outcome}"); + }); + })) +} diff --git a/crates/trusted-server-adapter-axum/src/identity_tests.rs b/crates/trusted-server-adapter-axum/src/identity_tests.rs new file mode 100644 index 000000000..fd162c2d8 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/identity_tests.rs @@ -0,0 +1,508 @@ +//! End-to-end identity-provider tests for the Axum adapter. +//! +//! Each test drives publisher navigations through the full router against a +//! local stub server bound to `127.0.0.1:0`. Requests to the fictional +//! `example.com` hosts are rewritten to the stub, so lookups go through the +//! real [`AxumPlatformHttpClient`] with its deadline handling. + +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use edgezero_core::http::{Request, Uri, header}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::ec::kv_types::KvEntry; +use trusted_server_core::geo::GeoInfo; +use trusted_server_core::platform::{ + ClientInfo, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, PlatformSelectResult, RuntimeServices, + UnavailableKvStore, +}; +use trusted_server_core::settings::Settings; + +use crate::app::{AppState, build_router, build_state_with_services}; +use crate::platform::{ + AxumPlatformBackend, AxumPlatformConfigStore, AxumPlatformHttpClient, AxumPlatformSecretStore, +}; + +const PUBLISHER_URL: &str = "https://publisher.example.com/article"; +const BROWSER_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; +const FIRST_PUBCID: &str = "11111111-1111-4111-8111-111111111111"; +const SECOND_PUBCID: &str = "22222222-2222-4222-8222-222222222222"; + +/// Local stub serving the publisher origin and the ID5 endpoint. +struct Stub { + address: String, + id5_requests: Arc, +} + +async fn start_stub() -> Stub { + let id5_requests = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&id5_requests); + let app = axum::Router::new().fallback(move |uri: axum::http::Uri| { + let counter = Arc::clone(&counter); + async move { + if uri.path().starts_with("/id5") { + counter.fetch_add(1, Ordering::SeqCst); + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + serde_json::json!({ + "universal_uid": "id5-example-uid", + "signature": "example-signature", + }) + .to_string(), + ) + } else { + ( + [(axum::http::header::CONTENT_TYPE, "text/html")], + "example".to_owned(), + ) + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind the stub server"); + let address = listener + .local_addr() + .expect("should read the stub address") + .to_string(); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("should serve stub requests"); + }); + Stub { + address, + id5_requests, + } +} + +/// Sends every request to the stub, prefixing the path with `/id5` for the +/// ID5 host so the stub can tell the upstreams apart. +struct StubRoutingClient { + inner: AxumPlatformHttpClient, + address: String, +} + +impl StubRoutingClient { + fn rewrite(&self, mut request: PlatformHttpRequest) -> PlatformHttpRequest { + let uri = request.request.uri(); + let prefix = if uri.host() == Some("id5.example.com") { + "/id5" + } else { + "" + }; + let path = uri.path_and_query().map_or("/", |path| path.as_str()); + let rewritten: Uri = format!("http://{}{prefix}{path}", self.address) + .parse() + .expect("should build the stub URI"); + *request.request.uri_mut() = rewritten; + request + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for StubRoutingClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send(self.rewrite(request)).await + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send_async(self.rewrite(request)).await + } + + async fn select( + &self, + pending_requests: Vec, + ) -> Result> { + self.inner.select(pending_requests).await + } + + async fn select_until( + &self, + pending_requests: Vec, + deadline: web_time::Instant, + ) -> Result, Report> { + self.inner.select_until(pending_requests, deadline).await + } +} + +struct FixedGeo { + country: &'static str, +} + +impl PlatformGeo for FixedGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(Some(GeoInfo { + country: self.country.to_owned(), + city: "Example City".to_owned(), + continent: "Example".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + })) + } +} + +fn services(stub: &Stub, country: &'static str) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(AxumPlatformConfigStore)) + .secret_store(Arc::new(AxumPlatformSecretStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(AxumPlatformBackend)) + .http_client(Arc::new(StubRoutingClient { + inner: AxumPlatformHttpClient::new(), + address: stub.address.clone(), + })) + .geo(Arc::new(FixedGeo { country })) + .client_info(ClientInfo { + client_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10))), + ..ClientInfo::default() + }) + .build() +} + +fn settings(providers: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "example-user" + password = "example-password" + + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.publisher.example.com" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-passphrase-at-least-32-bytes" + ec_store = "example_ec_store" + + [[ec.partners]] + name = "pubcid.org" + source_domain = "pubcid.org" + bidstream_enabled = true + + [[ec.partners]] + name = "id5.example.com" + source_domain = "id5.example.com" + bidstream_enabled = true + + {providers} + "# + )) + .expect("should parse identity test settings") +} + +const SHAREDID_PROVIDER: &str = r#" + [[ec.identity_providers]] + id = "sharedid" + kind = "sharedid" +"#; + +fn id5_provider(collect: &str) -> String { + format!( + r#" + [[ec.identity_providers]] + id = "id5" + kind = "id5" + endpoint = "https://id5.example.com/gs/v2" + allowed_domains = ["id5.example.com"] + sources = ["id5.example.com"] + collect = "{collect}" + [ec.identity_providers.settings] + partner_id = 1234 + "# + ) +} + +struct Harness { + state: Arc, + router: RouterService, + stub: Stub, +} + +async fn harness(providers: &str, country: &'static str) -> Harness { + let stub = start_stub().await; + let state = build_state_with_services(settings(providers), Some(services(&stub, country))) + .expect("should build state with identity providers"); + let router = build_router(&state); + Harness { + state, + router, + stub, + } +} + +impl Harness { + /// Sends a top-level document navigation and returns the `ts-ec` value + /// it set, if any. + async fn navigate(&self, cookie: &str) -> Option { + let request: Request = edgezero_core::http::request_builder() + .method("GET") + .uri(PUBLISHER_URL) + .header(header::HOST, "publisher.example.com") + .header(header::ACCEPT, "text/html") + .header(header::USER_AGENT, BROWSER_UA) + .header("sec-fetch-dest", "document") + .header("sec-fetch-mode", "navigate") + .header(header::COOKIE, cookie) + .body(edgezero_core::body::Body::empty()) + .expect("should build navigation"); + let response = self + .router + .oneshot(request) + .await + .expect("should serve navigation"); + assert_eq!(response.status(), 200, "should serve the origin page"); + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(|value| value.strip_prefix("ts-ec=")) + .and_then(|value| value.split(';').next()) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + } + + /// Mints an EC on a first navigation and returns it. + async fn mint_ec(&self, consent_cookie: &str) -> String { + self.navigate(consent_cookie) + .await + .expect("should mint an EC on the first navigation") + } + + fn entry(&self, ec_id: &str) -> Option { + self.state + .identity_graph() + .expect("should have an identity graph") + .get(ec_id) + .expect("should read the identity graph") + .map(|(entry, _)| entry) + } + + fn generation(&self, ec_id: &str) -> u64 { + self.state + .identity_graph() + .expect("should have an identity graph") + .get(ec_id) + .expect("should read the identity graph") + .expect("should find the EC row") + .1 + } + + fn stored_uids(&self, ec_id: &str) -> Vec { + self.entry(ec_id) + .map(|entry| entry.ids.values().map(|id| id.uid.clone()).collect()) + .unwrap_or_default() + } +} + +/// Encodes a minimal TCF v2 TC string granting `purposes`. +fn tc_string(purposes: &[u8]) -> String { + fn write_bits(buf: &mut [u8], offset: usize, width: usize, value: u64) { + for i in 0..width { + if (value >> (width - 1 - i)) & 1 == 1 { + let bit = offset + i; + buf[bit / 8] |= 1 << (7 - bit % 8); + } + } + } + // Core segment, empty vendor consent and legitimate-interest sections, + // and no publisher restrictions. + let mut buf = vec![0_u8; (213 + 17 + 17 + 12_usize).div_ceil(8)]; + let deciseconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("should read the clock") + .as_millis() + / 100; + let deciseconds = u64::try_from(deciseconds).expect("should fit in u64"); + write_bits(&mut buf, 0, 6, 2); + write_bits(&mut buf, 6, 36, deciseconds); + write_bits(&mut buf, 42, 36, deciseconds); + write_bits(&mut buf, 78, 12, 7); + write_bits(&mut buf, 90, 12, 1); + write_bits(&mut buf, 102, 6, 1); + write_bits(&mut buf, 108, 6, u64::from(b'E' - b'A')); + write_bits(&mut buf, 114, 6, u64::from(b'N' - b'A')); + write_bits(&mut buf, 120, 12, 42); + write_bits(&mut buf, 132, 6, 2); + for purpose in purposes { + write_bits(&mut buf, 152 + usize::from(*purpose) - 1, 1, 1); + } + write_bits(&mut buf, 201, 6, u64::from(b'E' - b'A')); + write_bits(&mut buf, 207, 6, u64::from(b'N' - b'A')); + URL_SAFE_NO_PAD.encode(&buf) +} + +fn consented() -> String { + format!("euconsent-v2={}", tc_string(&[1, 2, 3, 4, 7, 9, 10])) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_cookie_is_stored_and_kept_over_a_later_value() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + let consent = consented(); + let ec_id = harness.mint_ec(&consent).await; + + harness + .navigate(&format!("{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}")) + .await; + let first = harness.stored_uids(&ec_id); + harness + .navigate(&format!( + "{consent}; ts-ec={ec_id}; _pubcid={SECOND_PUBCID}" + )) + .await; + let second = harness.stored_uids(&ec_id); + + assert_eq!(first, vec![FIRST_PUBCID], "should store the SharedID"); + assert_eq!( + second, + vec![FIRST_PUBCID], + "should keep the stored SharedID over a different cookie value" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_opt_out_removes_the_stored_id() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + let consent = consented(); + let ec_id = harness.mint_ec(&consent).await; + harness + .navigate(&format!("{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}")) + .await; + assert_eq!( + harness.stored_uids(&ec_id), + vec![FIRST_PUBCID], + "should store the SharedID before the opt-out" + ); + + harness + .navigate(&format!( + "{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}; _pubcid_optout=1" + )) + .await; + + assert!( + harness.stored_uids(&ec_id).is_empty(), + "should remove the SharedID after an opt-out" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_opt_out_prevents_storing() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + let consent = consented(); + let ec_id = harness.mint_ec(&consent).await; + + harness + .navigate(&format!( + "{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}; _pubcid_optout=1" + )) + .await; + + assert!( + harness.entry(&ec_id).is_some(), + "should have written the EC row on the first navigation" + ); + assert!( + harness.stored_uids(&ec_id).is_empty(), + "should not store a SharedID while opted out" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sharedid_is_not_stored_without_eid_consent() { + let harness = harness(SHAREDID_PROVIDER, "DE").await; + // Purpose 1 allows the EC; Purpose 4 is denied, so no EID may be kept. + let consent = format!("euconsent-v2={}", tc_string(&[1])); + let ec_id = harness.mint_ec(&consent).await; + + harness + .navigate(&format!("{consent}; ts-ec={ec_id}; _pubcid={FIRST_PUBCID}")) + .await; + + assert!( + harness.entry(&ec_id).is_some(), + "should have written the EC row on the first navigation" + ); + assert!( + harness.stored_uids(&ec_id).is_empty(), + "should store no SharedID without Purpose 4 consent" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_phase_http_lookup_stores_the_vendor_id() { + let harness = harness(&id5_provider("page"), "AU").await; + let ec_id = harness.mint_ec("").await; + assert_eq!( + harness.stub.id5_requests.load(Ordering::SeqCst), + 0, + "should not look up an EC minted on this request" + ); + let minted_generation = harness.generation(&ec_id); + + harness.navigate(&format!("ts-ec={ec_id}")).await; + + assert_eq!( + harness.stub.id5_requests.load(Ordering::SeqCst), + 1, + "should call the vendor endpoint once" + ); + assert_eq!( + harness.stored_uids(&ec_id), + vec!["id5-example-uid"], + "should store the vendor ID in the page-load write" + ); + // The store hands out one generation per write across all keys. + assert_eq!( + harness.generation(&ec_id), + minted_generation + 1, + "should write the row once for the page load" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn after_response_http_lookup_stores_the_vendor_id() { + let harness = harness(&id5_provider("after_response"), "AU").await; + let ec_id = harness.mint_ec("").await; + + harness.navigate(&format!("ts-ec={ec_id}")).await; + + let mut stored = Vec::new(); + for _ in 0..100 { + stored = harness.stored_uids(&ec_id); + if !stored.is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert_eq!( + harness.stub.id5_requests.load(Ordering::SeqCst), + 1, + "should call the vendor endpoint once after the response" + ); + assert_eq!( + stored, + vec!["id5-example-uid"], + "should store the vendor ID in the follow-up write" + ); +} diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d..bc571b8c3 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -6,6 +6,12 @@ /// Application routing and handler registration for the Axum dev server. pub mod app; +/// Process-local EC identity store and rate limiter for the Axum dev server. +pub mod ec_kv; +/// EC identity lifecycle for publisher navigations. +mod identity; +#[cfg(test)] +mod identity_tests; /// Request middleware (auth, response finalisation). pub mod middleware; /// Platform-trait implementations backed by env vars and `reqwest`. From 117a58e0e4374d5c4c658024fd0ecc8ca4622a5d Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 12 Nov 2026 20:05:16 +0530 Subject: [PATCH 4/4] Document identity providers on the Axum dev server Signed-off-by: dhruv8sh --- docs/guide/configuration.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 65f214f6d..78721cda7 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -923,12 +923,14 @@ stored UIDs may still be live. Without `tcf_vendor_id`, a provider is gated by the EID-persistence purposes alone, so set it for any provider that has a TCF vendor ID. -Identity providers run only on Fastly. This release registers no provider kind, -so `ts config validate`, `push` and `diff` reject an enabled provider; keep -`enabled = false` until a kind ships. The Axum, Cloudflare and Spin adapters -refuse to start when an enabled provider is configured. On Fastly, an enabled -provider whose `kind` this build does not include, or whose `settings` its kind -rejects, also fails startup. Lookups always verify the provider's TLS +Identity providers run on Fastly and on the Axum dev server. This release +registers no provider kind, so `ts config validate`, `push` and `diff` reject an +enabled provider; keep `enabled = false` until a kind ships. The Cloudflare and +Spin adapters refuse to start when an enabled provider is configured. On Fastly +and Axum, an enabled provider whose `kind` this build does not include, or whose +`settings` its kind rejects, also fails startup. The Axum dev server keeps the +identity graph named by `ec.ec_store` in process memory, so stored IDs are lost +when it exits. Lookups always verify the provider's TLS certificate, whatever `proxy.certificate_check` says. A provider owns its `sources`: its results replace whatever the row holds for