Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
13d432c
Add a pluggable Edge Cookie provider seam with the built-in HMAC prov…
jwrosewell Aug 18, 2026
ea51812
Accept the provider-code envelope on the partner-facing identifier paths
jwrosewell Aug 27, 2026
1f4a70b
Rename the legacy passphrase migration so CodeQL stops tainting Settings
jwrosewell Aug 27, 2026
f0ca12a
Stop serving without identity when a selected provider is unavailable
jwrosewell Aug 28, 2026
b2bb944
Hold the deprecated EC passphrase to the same rules as the new block
jwrosewell Aug 28, 2026
472218b
Reject unknown keys in the built-in HMAC provider block
jwrosewell Aug 28, 2026
c4d2f1d
Stop rather than run stateless when the hmac block is missing
jwrosewell Aug 28, 2026
93cd1e8
Restore the missing line continuation in the mint rejection message
jwrosewell Aug 28, 2026
a265c96
Give EdgeCookieProvider its own doc comment back
jwrosewell Aug 28, 2026
004581c
Delete the unused ec::get_ec_id helper
jwrosewell Aug 28, 2026
d6041f0
Correct the provider module docs about when evidence arrives
jwrosewell Aug 28, 2026
885e3ce
Replace the scattered EC provider key strings with a typed selector
jwrosewell Aug 28, 2026
69649aa
Reserve core's own response surface against provider effects
jwrosewell Aug 29, 2026
53d632e
Dispatch partner-path identifier checks by provider code
jwrosewell Aug 29, 2026
941297f
Let each provider decide whether it needs the client IP
jwrosewell Aug 29, 2026
e317190
State a real retirement condition for the legacy bare-identifier reader
jwrosewell Aug 29, 2026
343ac3e
Key identity-graph reads and write-backs by the canonical form
jwrosewell Aug 29, 2026
8684c69
Egress only an Edge Cookie identifier the provider recognizes
jwrosewell Aug 29, 2026
20bb082
Record the cluster-count gap the identifier envelope opens
jwrosewell Aug 29, 2026
84925ca
Consume the refused Report in the testlight egress tests
jwrosewell Aug 29, 2026
e45990b
Drop the request-evidence accessors that have no caller
jwrosewell Aug 29, 2026
b146aeb
Collapse the EC provider selector to statelessness and a named provider
jwrosewell Aug 29, 2026
7c0428d
Accumulate provider response headers instead of replacing the origin's
jwrosewell Aug 29, 2026
69d5ed0
State what a provider switch really does to existing identities
jwrosewell Aug 29, 2026
60a1f4b
Name the design documents rather than their paths in doc comments
jwrosewell Aug 30, 2026
ada4d79
Restore the line continuations missed in the neighbouring files
jwrosewell Aug 30, 2026
4cf202f
Correct the two provider doc comments the earlier pass missed
jwrosewell Aug 30, 2026
abdd4cb
Stop a provider code from panicking when a vendor builds one at run time
jwrosewell Aug 30, 2026
138dc3a
Refuse two Edge Cookie providers claiming the same name
jwrosewell Aug 30, 2026
9f4061a
Build the internal header list from the Edge Cookie response headers
jwrosewell Aug 30, 2026
0f0722f
Resolve the Edge Cookie provider once per request instead of twice
jwrosewell Aug 30, 2026
a698f5b
Load Spin settings from the config store instead of a baked template
jwrosewell Aug 30, 2026
252aade
Stop exposing an inbound Edge Cookie identifier nothing has vouched for
jwrosewell Aug 30, 2026
6cc3c91
Keep the whole request-evidence interface
jwrosewell Aug 30, 2026
5d87e24
Documentation and house-style cleanup for the Edge Cookie provider PR
jwrosewell Aug 31, 2026
11cc575
Name the caching reason accurately in the reserved-header rejection
jwrosewell Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/edgecookie/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Edge Cookie providers

Vendor Edge Cookie provider crates live here, one per vendor, for example
`crates/edgecookie/<vendor>`. Each implements the `EdgeCookieProvider` trait
from `trusted-server-core` and is wired in by an adapter.

The built-in HMAC provider (HMAC over the client IP) ships in
`trusted-server-core` (`ec::provider`), so no crate is needed for it. There is
no default provider; a deployment selects one explicitly with `[ec] provider`.
This directory is a placeholder until a vendor provider is added.
130 changes: 114 additions & 16 deletions crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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::provider::ensure_provider_available;
use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError};
use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput};
Expand Down Expand Up @@ -69,11 +70,26 @@ fn build_state() -> Result<Arc<AppState>, Report<TrustedServerError>> {
///
/// # Errors
///
/// Returns an error when the auction orchestrator or the integration
/// registry fail to initialise.
/// Returns an error when the selected Edge Cookie provider cannot be built for
/// this adapter, or when the auction orchestrator or the integration registry
/// fail to initialize.
fn build_state_with_settings(
settings: Settings,
) -> Result<Arc<AppState>, Report<TrustedServerError>> {
// Composition root: reject a provider selection this adapter can never
// supply, once, before any request is served. The Axum dev server injects
// no Edge Cookie provider into `RuntimeServices`, so `None` is exactly what
// `EcContext` sees per request; pass the injected provider here as well
// once this adapter supplies one.
//
// This adapter checks rather than keeps what the check resolved, unlike the
// Fastly, Cloudflare and Spin adapters, because it is a long-lived process
// whose application state is built once at start-up while theirs is rebuilt
// for every request. It injects and threads no provider, so `EcContext`
// resolves the selection itself on every request, building a fresh built-in
// provider that reads no request data; this dev server accepts that
// per-request construction rather than caching a resolved provider.
ensure_provider_available(&settings.ec, None)?;
let orchestrator = build_orchestrator(&settings)?;
let registry = IntegrationRegistry::new(&settings)?;

Expand Down Expand Up @@ -153,13 +169,26 @@ where
/// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`,
/// `/_ts/page-bids`, and the publisher fallback).
///
/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction
/// Unknown, which fails the auction consent gate closed even for consented
/// users. Geo comes from the platform (a no-op on the local Axum dev server, so
/// jurisdiction stays Unknown there unless the request carries TCF consent). A
/// malformed consent string is logged and falls back to the default
/// (fail-closed) context rather than being silently swallowed.
fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext {
/// Geo comes from the platform (a no-op on the local Axum dev server, so
/// jurisdiction stays Unknown there unless the request carries TCF consent), and
/// a geo lookup failure is logged and treated as no location.
///
/// Mirrors the Fastly entry point, which keeps the report and answers with an
/// error response: when the Edge Cookie context cannot be read the request
/// fails rather than continuing with `EcContext::default()`, which would serve
/// every request with no identity. A malformed cookie value, a bad consent
/// string and a failed geo lookup do not reach this error path at all, so
/// failing here does not fail requests for ordinary parse problems.
///
/// # Errors
///
/// Returns an error when the selected Edge Cookie provider cannot be built for
/// this request, or when the request's `Cookie` header is not valid UTF-8.
fn build_ec_context(
state: &AppState,
services: &RuntimeServices,
req: &Request,
) -> Result<EcContext, Report<TrustedServerError>> {
let geo_info = services
.geo()
.lookup(services.client_info().client_ip)
Expand All @@ -168,10 +197,6 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request)
None
});
EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref())
.unwrap_or_else(|e| {
log::warn!("EC context read failed: {e:?}");
EcContext::default()
})
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -218,7 +243,7 @@ async fn dispatch_fallback(

// 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 mut ec_context = build_ec_context(state, services, &req)?;
let auction = AuctionDispatch {
orchestrator: &state.orchestrator,
slots: state.settings.creative_opportunity_slots(),
Expand Down Expand Up @@ -454,7 +479,7 @@ fn named_route_handler(
// Build the geo-aware EC context so the auction consent
// gate sees the caller's jurisdiction — `EcContext::default()`
// fails it closed for consented users.
let ec_context = build_ec_context(&state, &services, &req);
let ec_context = build_ec_context(&state, &services, &req)?;
handle_auction(
&state.settings,
&state.orchestrator,
Expand All @@ -473,7 +498,7 @@ fn named_route_handler(
if req.method() == Method::OPTIONS {
Ok(page_bids_preflight_denied())
} else {
let ec_context = build_ec_context(&state, &services, &req);
let ec_context = build_ec_context(&state, &services, &req)?;
let auction = AuctionDispatch {
orchestrator: &state.orchestrator,
slots: state.settings.creative_opportunity_slots(),
Expand Down Expand Up @@ -640,3 +665,76 @@ fn build_router(state: &Arc<AppState>) -> RouterService {

router.build()
}

#[cfg(test)]
mod tests {
use edgezero_core::http::request_builder;
use edgezero_core::params::PathParams;

use super::*;

/// Settings selecting a vendor Edge Cookie provider this adapter does not
/// inject, with the `[ec.providers.<key>]` block configuration validation
/// requires. `acme` is a fictional vendor key.
const UNINJECTED_PROVIDER_TOML: &str = r#"
[[handlers]]
path = "^/_ts/admin"
username = "admin"
password = "admin-pass"

[publisher]
domain = "test-publisher.example.com"
cookie_domain = ".test-publisher.example.com"
origin_url = "https://origin.test-publisher.example.com"
proxy_secret = "unit-test-proxy-secret"

[ec]
provider = "acme"

[ec.providers.acme]
endpoint = "https://ec.acme.example.com"
"#;

/// Builds application state directly, bypassing the composition root's
/// startup check, so the per-request behavior can be exercised with a
/// selection the adapter cannot supply.
fn state_with_uninjected_provider() -> AppState {
let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML)
.expect("should parse settings selecting an uninjected provider");
let orchestrator = build_orchestrator(&settings).expect("should build orchestrator");
let registry = IntegrationRegistry::new(&settings).expect("should build registry");
AppState {
settings: Arc::new(settings),
orchestrator: Arc::new(orchestrator),
registry: Arc::new(registry),
}
}

/// The per-request Edge Cookie read must return its error rather than a
/// default context.
///
/// This adapter used to log the failure and continue with
/// `EcContext::default()`, so a deployment whose selected provider could not
/// be built served every request with no identity. The call sites propagate
/// the error to `http_error`, matching the Fastly adapter.
#[test]
fn build_ec_context_fails_when_the_selected_provider_is_unavailable() {
let state = state_with_uninjected_provider();
let req = request_builder()
.method("POST")
.uri("https://test-publisher.example.com/auction")
.body(edgezero_core::body::Body::empty())
.expect("should build test request");
let ctx = RequestContext::new(req, PathParams::default());
let services = build_runtime_services(&ctx);
let req = ctx.into_request();

let error = build_ec_context(&state, &services, &req)
.expect_err("an unavailable Edge Cookie provider must fail the request");

assert!(
error.to_string().contains("acme"),
"the error should name the selected provider, got: {error}"
);
}
}
3 changes: 3 additions & 0 deletions crates/trusted-server-adapter-axum/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ mod tests {
proxy_secret = "unit-test-proxy-secret"

[ec]
provider = "hmac"

[ec.providers.hmac]
passphrase = "test-secret-key-32-bytes-minimum"
"#,
)
Expand Down
53 changes: 53 additions & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ fn test_router() -> edgezero_core::router::RouterService {
proxy_secret = "integration-test-proxy-secret"

[ec]
provider = "hmac"

[ec.providers.hmac]
passphrase = "test-secret-key-32-bytes-minimum"
"#,
)
Expand Down Expand Up @@ -829,3 +832,53 @@ async fn first_party_proxy_rebuild_is_routed() {
"/first-party/proxy-rebuild must be routed"
);
}

// ---------------------------------------------------------------------------
// Edge Cookie provider availability
// ---------------------------------------------------------------------------

/// Test settings selecting a vendor Edge Cookie provider this adapter does not
/// inject, with the `[ec.providers.<key>]` block configuration validation
/// requires. `acme` is a fictional vendor key.
const UNINJECTED_PROVIDER_TOML: &str = r#"
[[handlers]]
path = "^/_ts/admin"
username = "admin"
password = "admin-pass"

[publisher]
domain = "test-publisher.example.com"
cookie_domain = ".test-publisher.example.com"
origin_url = "https://origin.test-publisher.example.com"
proxy_secret = "integration-test-proxy-secret"

[ec]
provider = "acme"

[ec.providers.acme]
endpoint = "https://ec.acme.example.com"
"#;

/// A provider selection this adapter can never supply must fail while the
/// application state is built, before any request is served.
///
/// Configuration validation accepts this pair (the `[ec.providers.acme]` block
/// is present), and the Axum dev server injects no vendor Edge Cookie provider,
/// so only the composition root can catch it. Without the startup check the
/// deployment would come up and answer every request.
#[test]
fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() {
let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_PROVIDER_TOML)
.expect("should parse settings selecting an uninjected provider");

// `RouterService` is not `Debug`, so take the error side directly rather
// than through `expect_err`.
let error = trusted_server_adapter_axum::app::TrustedServerApp::routes_with_settings(settings)
.err()
.expect("building state with an uninjected provider should fail");

assert!(
error.to_string().contains("acme"),
"the startup error should name the selected provider, got: {error}"
);
}
Loading
Loading