Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
test-rust:
name: cargo test
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking:** Auction providers and bidder routes now use the configuration-first `[auction.providers.<id>]` and `[auction.bidders.<id>]` maps. The removed `[auction].providers = [...]` list and removed server fields under `[integrations.prebid]` and `[integrations.aps]` are rejected even when those integrations are disabled, and `ts config push` rejects the old shape before publication. Move PBS `server_url` to provider `endpoint`, server timeout to provider `timeout_ms`, request controls and bidder-parameter overrides to the `prebid-server` `profile_config`, notification suppression to `notifications`, and each former server bidder to an `[auction.bidders.<id>]` route. Move APS endpoint, timeout, account, inventory, debug, and creative controls to an `aps` provider and its `profile_config`. Browser Prebid settings remain under `[integrations.prebid]`; values such as timeout and debug that previously affected both browser and server behavior must now be configured for each owner. Provider endpoints must be absolute HTTPS URLs. Only bidder codes present in `[auction.bidders]` are folded into Trusted Server requests; unlisted publisher bids remain native browser demand. This schema has no mixed-version-safe deployment order: old binaries reject the maps and new binaries reject the retired fields, so activate the new binary and config blob together. Rollbacks must restore an old-schema blob together with the old binary.
- **Breaking** — Admin Basic-auth coverage now includes `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}`, and `GET /_ts/admin/eids`. Existing configurations whose `[[handlers]]` patterns protect only the key-management endpoints now fail startup; broaden coverage before deploying, preferably with a namespace-boundary pattern such as `^/_ts/admin(?:/|$)`. Coverage of the dynamic `/_ts/admin/ec/{id}` route is no longer inferred from ID-shaped samples: the router accepts any segment after `/_ts/admin/ec/` and Basic Auth runs on the raw path before routing, so patterns anchored to the EC ID grammar (for example `^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$`) are rejected in favor of a prefix-level matcher. Placeholder and well-known weak handler passwords (`changeme`, `password`, `admin`, `replace-with-…`) now fail startup on every handler rather than only on handlers inferred to cover an admin endpoint, because first-match-wins handler selection lets a narrow handler shadow the admin namespace.
- Prebid Server provider endpoints now normalize origin-only legacy `server_url` values to `/openrtb2/auction`. Query parameters are preserved, the canonical path loses a trailing slash, and configured non-root custom paths remain exact.
- Publisher HTML uses the browser-only `Cache-Control: private, max-age=60` policy for successful GET document responses and their `304 Not Modified` revalidations when server-side ad templates are structurally inactive, while preserving origin `private`/`no-store` policies and request-scoped bot, prefetch, or consent-denied responses. The `private` directive prevents shared caches that use `Cache-Control` from storing the document. Cookie-bearing responses using the generated inactive policy are finalized as `private, max-age=0`; CDN-specific cache headers remain unchanged and continue to control supporting CDNs independently. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers; an absent configuration, an unmatched slot, or a disabled auction also make the stack structurally inactive. An explicit `enabled = false` is not compatible with older binaries: restore the default, re-push and finalize the config before rolling back.
- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape.
- **Breaking** — All auction paths now forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the existing Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters.
Expand Down
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ ts --help

# Create local config, then edit placeholders before validation
ts config init
# Edit trusted-server.toml
# Edit trusted-server.toml. Server auctions use map-shaped
# [auction.providers.<id>] and [auction.bidders.<id>] tables.
ts config validate

# Audit a public page with Chrome/Chromium to bootstrap a draft config
Expand Down
54 changes: 29 additions & 25 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,39 @@ curl -X POST http://localhost:7676/auction \
- Optional mock-adserver mediation selecting winning bids
- Final response with winning creatives

**With Orchestrator Disabled** (`auction.enabled = false`):
- Logs showing: `"Using legacy Prebid flow"`
- Direct Prebid Server call (backward compatible)
**With Auction Execution Disabled** (`auction.enabled = false`):
- Logs showing: `"/auction: auction is disabled; returning no-bid response"`
- Immediate no-bid response with no provider or mediator dispatch

##Configuration
## Configuration

Edit `trusted-server.toml` to customize the auction:

```toml
# Enable/disable orchestrator
[auction]
enabled = true
providers = ["prebid", "aps"]
mediator = "adserver_mock" # If set: mediation, if omitted: highest bid wins
timeout_ms = 2000
mediator = "adserver_mock"

# APS OpenRTB provider. The built-in production endpoint is used when
# endpoint is omitted; use only an account authorized for test traffic.
[integrations.aps]
enabled = true
account_id = "example-account"
timeout_ms = 800
debug = false
[auction.providers.pbs-main]
protocol = "openrtb-2.6"
profile = "prebid-server"
endpoint = "https://prebid.example.com/openrtb2/auction"
routing = "explicit"

[auction.providers.aps-main]
protocol = "openrtb-2.6"
profile = "aps"
endpoint = "https://aps.example.com/e/pb/bid"
routing = "all_eligible"
profile_config = { account_id = "example-aps-account", debug = false }

[auction.bidders.example-server]
provider = "pbs-main"

[integrations.adserver_mock]
enabled = true
endpoint = "http://localhost:6767/adserver/mediate"
endpoint = "https://mediator.example.com/mediate"
timeout_ms = 500
```

Expand All @@ -87,8 +93,7 @@ timeout_ms = 500
```toml
[auction]
enabled = true
providers = ["prebid", "aps"]
mediator = "adserver_mock" # Mediator configured = parallel mediation strategy
mediator = "adserver_mock" # Providers come from [auction.providers.*] maps
```

**Expected Flow:**
Expand All @@ -102,25 +107,24 @@ mediator = "adserver_mock" # Mediator configured = parallel mediation strategy
```toml
[auction]
enabled = true
providers = ["prebid", "aps"]
# No mediator = parallel only strategy
# Configured [auction.providers.*] run without a mediator
```

**Expected Flow:**
1. Prebid and APS run in parallel
2. Highest bid wins automatically
3. No mediation

### Scenario 3: Legacy Mode (Backward Compatible)
### Scenario 3: Auction Disabled

**Config:**

```toml
[auction]
enabled = false
```

**Expected Flow:**
- Original Prebid-only behavior
- No orchestration overhead
**Expected Flow:** no auction provider dispatch.

## Debugging

Expand Down Expand Up @@ -149,10 +153,10 @@ INFO: Registering auction provider: adserver_mock
### Common Issues

**Issue:** `"Provider 'aps' not registered"`
**Fix:** Make sure `[integrations.aps]` is configured in `trusted-server.toml`
**Fix:** Make sure an `[auction.providers.<id>]` entry selects `profile = "aps"`

**Issue:** `"No providers configured"`
**Fix:** Make sure `providers = ["prebid", "aps"]` is set in `[auction]`
**Fix:** Make sure map-shaped `[auction.providers.<id>]` entries are configured

**Issue:** Tests fail with WASM errors
**Explanation:** Async tests don't work in WASM test environment. Integration tests via HTTP work fine!
Expand Down
10 changes: 7 additions & 3 deletions crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use edgezero_core::http::{
use edgezero_core::router::RouterService;
use error_stack::Report;
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
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::{
Expand Down Expand Up @@ -74,8 +76,10 @@ fn build_state() -> Result<Arc<AppState>, Report<TrustedServerError>> {
fn build_state_with_settings(
settings: Settings,
) -> Result<Arc<AppState>, Report<TrustedServerError>> {
let orchestrator = build_orchestrator(&settings)?;
let registry = IntegrationRegistry::new(&settings)?;
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)?;

Ok(Arc::new(AppState {
settings: Arc::new(settings),
Expand Down
74 changes: 54 additions & 20 deletions crates/trusted-server-adapter-axum/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ use async_trait::async_trait;
use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header};
use error_stack::{Report, ResultExt as _};
use trusted_server_core::platform::{
ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError,
PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse,
PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName,
BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec,
PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest,
PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult,
RuntimeServices, StoreId, StoreName,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -154,24 +155,15 @@ impl PlatformSecretStore for AxumPlatformSecretStore {
pub struct AxumPlatformBackend;

impl PlatformBackend for AxumPlatformBackend {
fn naming_policy(&self) -> BackendNamingPolicy {
BackendNamingPolicy::Axum
}

fn predict_name(&self, spec: &PlatformBackendSpec) -> Result<String, Report<PlatformError>> {
let port = spec
.port
.unwrap_or(if spec.scheme == "https" { 443 } else { 80 });
// Keep two providers that share an origin on distinct names so auction
// response correlation cannot cross providers.
let discriminator = spec
.discriminator
.as_deref()
.map(|d| format!("_p_{}", normalize_env_segment(d)))
.unwrap_or_default();
Ok(format!(
"{}_{}_{}{}",
normalize_env_segment(&spec.scheme),
normalize_env_segment(&spec.host),
port,
discriminator,
))
self.naming_policy()
.predict(spec)
.map(|prediction| prediction.name)
.change_context(PlatformError::Backend)
}

fn ensure(&self, spec: &PlatformBackendSpec) -> Result<String, Report<PlatformError>> {
Expand Down Expand Up @@ -601,6 +593,21 @@ mod tests {
use std::time::Duration;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};

#[test]
fn auction_http_capabilities_are_explicit() {
let client = AxumPlatformHttpClient::new();
let capabilities = trusted_server_core::platform::AuctionTargetId::Axum
.descriptor()
.capabilities();
assert!(client.supports_concurrent_fanout());
assert!(capabilities.supports_concurrent_provider_fanout());
assert!(!client.has_enforceable_total_request_deadline());
assert!(
!capabilities.has_enforceable_total_request_deadline(),
"reqwest's transport timeout is not an adapter-enforced auction deadline"
);
}

#[test]
fn config_store_reads_from_env_var() {
temp_env::with_var(
Expand Down Expand Up @@ -693,6 +700,33 @@ mod tests {
assert!(with_ip.is_none(), "should return None for any IP");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_client_surfaces_redirect_without_following() {
let url = serve_raw_response(
b"HTTP/1.1 302 Found\r\nLocation: https://redirect.example/next\r\nContent-Length: 0\r\n\r\n",
)
.await;
let request = edgezero_core::http::request_builder()
.uri(url)
.body(EdgeBody::empty())
.expect("should build outbound request");

let response = AxumPlatformHttpClient::new()
.send(PlatformHttpRequest::new(request, "test_backend"))
.await
.expect("should surface redirect")
.response;

assert_eq!(response.status().as_u16(), 302);
assert_eq!(
response
.headers()
.get(edgezero_core::http::header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("https://redirect.example/next")
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_client_strips_hop_by_hop_response_headers() {
let url = serve_raw_response(
Expand Down
46 changes: 42 additions & 4 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] =
/// The settings baked into the binary contain placeholder secrets that
/// `get_settings()` rejects by design, which would turn every route into a
/// startup error page (and its route table into the fallback-only set).
fn test_router() -> edgezero_core::router::RouterService {
let settings = trusted_server_core::settings::Settings::from_toml(
fn test_settings() -> trusted_server_core::settings::Settings {
trusted_server_core::settings::Settings::from_toml(
r#"
[[handlers]]
path = "^/_ts/admin"
Expand All @@ -36,9 +36,11 @@ fn test_router() -> edgezero_core::router::RouterService {
passphrase = "test-secret-key-32-bytes-minimum"
"#,
)
.expect("should parse route test settings");
.expect("should parse route test settings")
}

TrustedServerApp::routes_with_settings(settings)
fn test_router() -> edgezero_core::router::RouterService {
TrustedServerApp::routes_with_settings(test_settings())
.expect("should build router from test settings")
}

Expand All @@ -62,6 +64,42 @@ fn assert_route_registered(method: &str, path: &str) {
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn aps_profile_serves_renderer_through_adapter_fallback() {
let mut settings = test_settings();
settings.auction.providers.insert(
"aps-main".parse().expect("should parse APS provider ID"),
trusted_server_core::auction::ProviderConfig {
protocol: "openrtb-2.6".to_string(),
profile: "aps".to_string(),
endpoint: "https://aps.example/e/pb/bid".to_string(),
timeout_ms: None,
routing: trusted_server_core::auction::RoutingMode::AllEligible,
notifications: trusted_server_core::auction::NotificationConfig::default(),
profile_config: "{\"account_id\":\"example-account\"}"
.parse()
.expect("should parse APS profile config"),
},
);
let router = TrustedServerApp::routes_with_settings(settings)
.expect("should build router with APS profile");
let mut service = EdgeZeroAxumService::new(router);
let request = Request::builder()
.method("GET")
.uri("/integrations/aps/renderer")
.body(AxumBody::empty())
.expect("should build APS renderer request");

let response = service
.ready()
.await
.expect("should be ready")
.call(request)
.await
.expect("should serve APS renderer");
assert_eq!(response.status().as_u16(), 200);
}

/// Verify that every expected explicit route is registered in the route table.
///
/// Uses [`RouterService::routes()`] for introspection rather than checking
Expand Down
Loading
Loading