Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
b740265
Add a pluggable Edge Cookie provider seam with the built-in HMAC prov…
jwrosewell Aug 18, 2026
d2ad2a9
Accept the provider-code envelope on the partner-facing identifier paths
jwrosewell Aug 27, 2026
b2623cb
Rename the legacy passphrase migration so CodeQL stops tainting Settings
jwrosewell Aug 27, 2026
918f96c
Stop serving without identity when a selected provider is unavailable
jwrosewell Aug 28, 2026
61b7cf1
Hold the deprecated EC passphrase to the same rules as the new block
jwrosewell Aug 28, 2026
5b01f98
Reject unknown keys in the built-in HMAC provider block
jwrosewell Aug 28, 2026
7424932
Stop rather than run stateless when the hmac block is missing
jwrosewell Aug 28, 2026
d6dc848
Restore the missing line continuation in the mint rejection message
jwrosewell Aug 28, 2026
45ec829
Give EdgeCookieProvider its own doc comment back
jwrosewell Aug 28, 2026
c4c584c
Delete the unused ec::get_ec_id helper
jwrosewell Aug 28, 2026
5203c2d
Correct the provider module docs about when evidence arrives
jwrosewell Aug 28, 2026
e44ff38
Replace the scattered EC provider key strings with a typed selector
jwrosewell Aug 28, 2026
afdb7be
Reserve core's own response surface against provider effects
jwrosewell Aug 29, 2026
e90b471
Dispatch partner-path identifier checks by provider code
jwrosewell Aug 29, 2026
3b74d74
Let each provider decide whether it needs the client IP
jwrosewell Aug 29, 2026
29a3a7f
State a real retirement condition for the legacy bare-identifier reader
jwrosewell Aug 29, 2026
cb62786
Key identity-graph reads and write-backs by the canonical form
jwrosewell Aug 29, 2026
fe23dcc
Egress only an Edge Cookie identifier the provider recognizes
jwrosewell Aug 29, 2026
e3ba579
Record the cluster-count gap the identifier envelope opens
jwrosewell Aug 29, 2026
292df1f
Consume the refused Report in the testlight egress tests
jwrosewell Aug 29, 2026
a3a5d57
Drop the request-evidence accessors that have no caller
jwrosewell Aug 29, 2026
b70ddc0
Collapse the EC provider selector to statelessness and a named provider
jwrosewell Aug 29, 2026
5da52c8
Accumulate provider response headers instead of replacing the origin's
jwrosewell Aug 29, 2026
67c3b0b
State what a provider switch really does to existing identities
jwrosewell Aug 29, 2026
1f62e1a
Name the design documents rather than their paths in doc comments
jwrosewell Aug 30, 2026
c7464bc
Restore the line continuations missed in the neighbouring files
jwrosewell Aug 30, 2026
ef81e88
Correct the two provider doc comments the earlier pass missed
jwrosewell Aug 30, 2026
acb416f
Stop a provider code from panicking when a vendor builds one at run time
jwrosewell Aug 30, 2026
8eb0a9b
Refuse two Edge Cookie providers claiming the same name
jwrosewell Aug 30, 2026
f2b1825
Build the internal header list from the Edge Cookie response headers
jwrosewell Aug 30, 2026
52e2692
Resolve the Edge Cookie provider once per request instead of twice
jwrosewell Aug 30, 2026
1c7df60
Load Spin settings from the config store instead of a baked template
jwrosewell Aug 30, 2026
3234092
Stop exposing an inbound Edge Cookie identifier nothing has vouched for
jwrosewell Aug 30, 2026
1df8bf7
Keep the whole request-evidence interface
jwrosewell Aug 30, 2026
8a29373
Documentation and house-style cleanup for the Edge Cookie provider PR
jwrosewell Aug 31, 2026
147a37b
Name the caching reason accurately in the reserved-header rejection
jwrosewell Aug 31, 2026
7dda9dd
Collapse Edge Cookie provider injection to a single seam
jwrosewell Aug 31, 2026
c16c99d
Remove the orphaned no-client-IP test fixture
jwrosewell Sep 1, 2026
ebf0117
Merge branch 'main' into split/1-ec-provider
aram356 Sep 4, 2026
af2e2f5
Merge main into the Edge Cookie provider seam
jwrosewell Sep 14, 2026
cb28ad3
Build the Axum test state from the compiled auction plan
jwrosewell Sep 14, 2026
0980b73
Describe rejected provider effects accurately
jwrosewell Sep 14, 2026
0e7f7eb
Key the remaining identity-graph paths canonically
jwrosewell Sep 14, 2026
fb1bc28
Correct comment claims and test a stale preload read
jwrosewell Sep 14, 2026
cb6f717
Keep provider headers only from candidates that generation commits
jwrosewell Sep 14, 2026
1b88e42
Correct the admin lookup forms and the bare reader row note
jwrosewell Sep 14, 2026
14a3ffb
Give each identity provider its own table under [ec]
jwrosewell Sep 15, 2026
4d40e5c
Merge main into the Edge Cookie provider seam
jwrosewell Sep 25, 2026
5b83034
Give the test state the services field main added
jwrosewell Sep 25, 2026
53f16ab
Merge upstream main 7a0ecb4 into split/1-ec-provider
jwrosewell Oct 6, 2026
797c919
Give orphan recovery the request's headers, path and query
jwrosewell Oct 6, 2026
fbe302a
Delete the cookie reading test that only read its own double
jwrosewell Oct 6, 2026
977456e
Test that push validation keeps a labeled block's other errors
jwrosewell Oct 6, 2026
4da2a39
Bring the Edge Cookie seam's comments, docs and tests into line
jwrosewell Oct 6, 2026
1e25353
The Edge Cookie seam says module where it said provider
jwrosewell Oct 6, 2026
6b2e303
A module is named by its crate folder
jwrosewell Oct 7, 2026
ef4c013
Comments say what the code does, with the history taken out
jwrosewell Oct 7, 2026
5d808f2
Say what normalize_id_for_kv decides, and pin it with a fixture
jwrosewell Oct 7, 2026
6f52771
The identity graph's comments describe device records, not person rec…
jwrosewell Oct 7, 2026
d33507b
Merge upstream main 182fdf4 into split/1-ec-provider
jwrosewell Oct 8, 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
18 changes: 18 additions & 0 deletions crates/edgecookie/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Edge Cookie modules

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

The built-in HMAC module (HMAC over the client IP) ships in
`trusted-server-core` (`ec::module`), so no crate is needed for it. There is
no default module, and a deployment selects one explicitly with
`[ec] module`.

A module's own settings live in the `[ec.<name>]` table the selector names.
The name is the module's implementation id, the same string its
`EdgeCookieModule::id` returns, unless the table names one with
`implementation = "<id>"`, which lets an operator configure a module under a
name of their own choosing. A module with no settings needs no table.

This directory is a placeholder until a vendor module is added.
141 changes: 125 additions & 16 deletions crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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::module::ensure_module_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 @@ -77,8 +78,9 @@ 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 module 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>> {
Expand All @@ -89,6 +91,26 @@ fn build_state_with_services(
settings: Settings,
services: Option<RuntimeServices>,
) -> Result<Arc<AppState>, Report<TrustedServerError>> {
// Composition root: reject a module selection this adapter can never
// supply, once, before any request is served. A caller supplying its own
// `RuntimeServices` may already have resolved a module, so the check is
// given whatever those services carry, which is what `EcContext` sees per
// request.
//
// 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. With no services supplied it threads no module, so
// `EcContext` resolves the selection itself on every request, building a
// fresh built-in module that reads no request data; this dev server
// accepts that per-request construction rather than caching a resolved
// module.
ensure_module_available(
&settings.ec,
services
.as_ref()
.and_then(RuntimeServices::resolved_ec_module),
)?;
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)?;
Expand Down Expand Up @@ -179,13 +201,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 module 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 @@ -194,10 +229,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 @@ -244,7 +275,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 @@ -505,7 +536,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 mut ec_context = build_ec_context(&state, &services, &req);
let mut ec_context = build_ec_context(&state, &services, &req)?;
handle_auction(
&state.settings,
&state.orchestrator,
Expand All @@ -524,7 +555,7 @@ fn named_route_handler(
if req.method() == Method::OPTIONS {
Ok(page_bids_preflight_denied())
} else {
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 @@ -715,3 +746,81 @@ 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 module this adapter does not
/// inject, with the `[ec.acme]` block that module's settings live in.
/// `acme` is a fictional vendor key.
const UNINJECTED_MODULE_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]
module = "acme"

[ec.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_module() -> AppState {
let settings = Settings::from_toml(UNINJECTED_MODULE_TOML)
.expect("should parse settings selecting an uninjected module");
let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan"));
let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)
.expect("should build orchestrator");
let registry =
IntegrationRegistry::with_plan(&settings, plan).expect("should build registry");
AppState {
settings: Arc::new(settings),
orchestrator: Arc::new(orchestrator),
registry: Arc::new(registry),
// This test drives the per-request path, which builds its services
// from the request context.
services: None,
}
}

/// The per-request Edge Cookie read must return its error rather than a
/// default context.
///
/// Continuing with `EcContext::default()` would serve every request with
/// no identity when the selected module cannot be built. The call sites
/// propagate the error to `http_error`, matching the Fastly adapter.
#[test]
fn build_ec_context_fails_when_the_selected_module_is_unavailable() {
let state = state_with_uninjected_module();
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 module must fail the request");

assert!(
error.to_string().contains("acme"),
"the error should name the selected module, 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]
module = "hmac"

[ec.hmac]
passphrase = "test-secret-key-32-bytes-minimum"
"#,
)
Expand Down
54 changes: 54 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_settings() -> trusted_server_core::settings::Settings {
proxy_secret = "integration-test-proxy-secret"

[ec]
module = "hmac"

[ec.hmac]
passphrase = "test-secret-key-32-bytes-minimum"
"#,
)
Expand Down Expand Up @@ -868,6 +871,57 @@ async fn first_party_proxy_rebuild_is_routed() {
);
}

// ---------------------------------------------------------------------------
// Edge Cookie module availability
// ---------------------------------------------------------------------------

/// Test settings selecting a vendor Edge Cookie module this adapter does not
/// inject, with the `[ec.acme]` block that module's settings live in.
/// `acme` is a fictional vendor key.
const UNINJECTED_MODULE_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]
module = "acme"

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

/// A module selection this adapter can never supply must fail while the
/// application state is built, before any request is served.
///
/// Configuration validation accepts this selection, because only the adapter
/// that injects a module knows what that module needs, and the Axum dev
/// server injects no vendor Edge Cookie module, 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_module_this_adapter_cannot_supply_fails_at_startup() {
let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_MODULE_TOML)
.expect("should parse settings selecting an uninjected module");

// `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 module should fail");

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

/// Regression test: a Next.js navigation with a pending auction must buffer to
/// the structural body close. The Flight payload carries a literal `</body>`, so
/// a parser-blind seam would inject bids early and split the RSC data.
Expand Down
Loading
Loading