Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions .claude/agents/pr-creator.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Using the `.github/pull_request_template.md` structure, draft:
- **Changes table**: list each file modified and what changed.
- **Closes**: `Closes #<issue-number>` to auto-close the linked issue.
- **Test plan**: check off which verification steps were run.
- **Hardening note**: when config-derived regex or pattern compilation is touched, state how invalid enabled config fails startup and which regression tests cover that path.
- **Checklist**: verify each item applies.

### 5. Create the PR
Expand Down Expand Up @@ -172,6 +173,7 @@ Do **not** use labels as a substitute for types.
- Use sentence case for the title.
- Use imperative mood (e.g., "Add caching to proxy" not "Added caching").
- The summary should focus on _why_, not just _what_.
- Do not describe config-derived regex/pattern compilation as safe unless invalid enabled config is handled without `panic!`, `unwrap()`, or `expect()`.
- Always base PRs against `main` unless told otherwise.
- Always assign the PR to the current user (`--assignee @me`).
- Never force-push or rebase without explicit user approval.
Expand Down
3 changes: 3 additions & 0 deletions .claude/agents/pr-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ For each changed file, evaluate:
- `expect("should ...")` instead of `unwrap()` in production code
- `error-stack` (`Report<E>`) with `derive_more::Display` for errors (not thiserror/anyhow)
- `log` macros (not `println!`)
- Config-derived regex/pattern compilation must not use panic-prone `expect()`/`unwrap()`; invalid enabled config should surface as startup/config errors
- Invalid enabled integrations/providers must not be silently logged-and-disabled during startup or registration
- `vi.hoisted()` for mock definitions in JS tests
- Integration IDs match JS directory names
- Colocated tests with `#[cfg(test)]`
Expand Down Expand Up @@ -105,6 +107,7 @@ For each changed file, evaluate:

- Are new code paths tested?
- Are edge cases covered (empty input, max values, error paths)?
- If config-derived regex/pattern compilation changed: are invalid enabled-config startup failures and explicit `enabled = false` bypass cases both covered?
- Rust tests: `cargo test --workspace`
- JS tests: `npx vitest run` in `crates/js/lib/`

Expand Down
19 changes: 14 additions & 5 deletions crates/common/src/auction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
//! Note: Individual auction providers are located in the `integrations` module
//! (e.g., `crate::integrations::aps`, `crate::integrations::prebid`).

use error_stack::Report;

use crate::error::TrustedServerError;
use crate::settings::Settings;
use std::sync::Arc;

Expand All @@ -27,7 +30,8 @@ pub use types::{
};

/// Type alias for provider builder functions.
type ProviderBuilder = fn(&Settings) -> Vec<Arc<dyn AuctionProvider>>;
type ProviderBuilder =
fn(&Settings) -> Result<Vec<Arc<dyn AuctionProvider>>, Report<TrustedServerError>>;

/// Returns the list of all available provider builder functions.
///
Expand All @@ -49,15 +53,20 @@ fn provider_builders() -> &'static [ProviderBuilder] {
///
/// # Arguments
/// * `settings` - Application settings used to configure the orchestrator and providers
#[must_use]
pub fn build_orchestrator(settings: &Settings) -> AuctionOrchestrator {
///
/// # Errors
///
/// Returns an error when an enabled auction provider has invalid configuration.
pub fn build_orchestrator(
settings: &Settings,
) -> Result<AuctionOrchestrator, Report<TrustedServerError>> {
log::info!("Building auction orchestrator");

let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone());

// Auto-discover and register all auction providers from settings
for builder in provider_builders() {
for provider in builder(settings) {
for provider in builder(settings)? {
orchestrator.register_provider(provider);
}
}
Expand All @@ -67,5 +76,5 @@ pub fn build_orchestrator(settings: &Settings) -> AuctionOrchestrator {
orchestrator.provider_count()
);

orchestrator
Ok(orchestrator)
}
61 changes: 51 additions & 10 deletions crates/common/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use error_stack::Report;
use fastly::http::{header, StatusCode};
use fastly::{Request, Response};

use crate::error::TrustedServerError;
use crate::settings::Settings;

const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#;

pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option<Response> {
let handler = settings.handler_for_path(req.get_path())?;
/// Enforce HTTP basic auth for the matched handler, if any.
///
/// Returns `Ok(None)` when the request does not target a protected handler or
/// when the supplied credentials are valid. Returns `Ok(Some(Response))` with
/// the auth challenge when credentials are missing or invalid.
///
/// # Errors
///
/// Returns an error when handler configuration is invalid, such as an
/// un-compilable path regex.
pub fn enforce_basic_auth(
settings: &Settings,
req: &Request,
) -> Result<Option<Response>, Report<TrustedServerError>> {
let Some(handler) = settings.handler_for_path(req.get_path())? else {
return Ok(None);
};

let (username, password) = match extract_credentials(req) {
Some(credentials) => credentials,
None => return Some(unauthorized_response()),
None => return Ok(Some(unauthorized_response())),
};

if username == handler.username && password == handler.password {
None
Ok(None)
} else {
Some(unauthorized_response())
Ok(Some(unauthorized_response()))
}
}

Expand Down Expand Up @@ -72,15 +89,19 @@ mod tests {
let settings = settings_with_handlers();
let req = Request::new(Method::GET, "https://example.com/open");

assert!(enforce_basic_auth(&settings, &req).is_none());
assert!(enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.is_none());
}

#[test]
fn challenge_when_missing_credentials() {
let settings = settings_with_handlers();
let req = Request::new(Method::GET, "https://example.com/secure");

let response = enforce_basic_auth(&settings, &req).expect("should challenge");
let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
let realm = response
.get_header(header::WWW_AUTHENTICATE)
Expand All @@ -95,7 +116,9 @@ mod tests {
let token = STANDARD.encode("user:pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

assert!(enforce_basic_auth(&settings, &req).is_none());
assert!(enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.is_none());
}

#[test]
Expand All @@ -105,7 +128,9 @@ mod tests {
let token = STANDARD.encode("user:wrong");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req).expect("should challenge");
let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

Expand All @@ -115,7 +140,23 @@ mod tests {
let mut req = Request::new(Method::GET, "https://example.com/secure");
req.set_header(header::AUTHORIZATION, "Bearer token");

let response = enforce_basic_auth(&settings, &req).expect("should challenge");
let response = enforce_basic_auth(&settings, &req)
.expect("should evaluate auth")
.expect("should challenge");
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

#[test]
fn returns_error_for_invalid_handler_regex_without_panicking() {
let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "(""#);
let settings = Settings::from_toml(&config).expect("should parse invalid regex TOML");
let req = Request::new(Method::GET, "https://example.com/secure");

let err = enforce_basic_auth(&settings, &req).expect_err("should return config error");
assert!(
err.to_string()
.contains("Handler path regex `(` failed to compile"),
"should describe the invalid handler regex"
);
}
}
15 changes: 11 additions & 4 deletions crates/common/src/integrations/adserver_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub struct AdServerMockConfig {
pub enabled: bool,

/// Mediation endpoint URL
#[validate(url)]
pub endpoint: String,

/// Timeout in milliseconds
Expand Down Expand Up @@ -379,8 +380,14 @@ impl AuctionProvider for AdServerMockProvider {
// ============================================================================

/// Auto-register ad server mock provider based on settings configuration.
#[must_use]
pub fn register_providers(settings: &Settings) -> Vec<Arc<dyn AuctionProvider>> {
///
/// # Errors
///
/// Returns an error when the ad server mock provider is enabled with invalid
/// configuration.
pub fn register_providers(
settings: &Settings,
) -> Result<Vec<Arc<dyn AuctionProvider>>, Report<TrustedServerError>> {
let mut providers: Vec<Arc<dyn AuctionProvider>> = Vec::new();

match settings.integration_config::<AdServerMockConfig>("adserver_mock") {
Expand All @@ -395,11 +402,11 @@ pub fn register_providers(settings: &Settings) -> Vec<Arc<dyn AuctionProvider>>
log::debug!("AdServer Mock config found but is disabled");
}
Err(e) => {
log::error!("Failed to load AdServer Mock config: {:?}", e);
return Err(e);
}
}

providers
Ok(providers)
}

// ============================================================================
Expand Down
15 changes: 11 additions & 4 deletions crates/common/src/integrations/aps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ pub struct ApsConfig {

/// APS API endpoint
#[serde(default = "default_endpoint")]
#[validate(url)]
pub endpoint: String,

/// Timeout in milliseconds
Expand Down Expand Up @@ -532,8 +533,14 @@ use std::sync::Arc;
/// Auto-register APS provider based on settings configuration.
///
/// Returns the APS provider if enabled in settings.
#[must_use]
pub fn register_providers(settings: &Settings) -> Vec<Arc<dyn AuctionProvider>> {
///
/// # Errors
///
/// Returns an error when the APS provider is enabled with invalid
/// configuration.
pub fn register_providers(
settings: &Settings,
) -> Result<Vec<Arc<dyn AuctionProvider>>, Report<TrustedServerError>> {
let mut providers: Vec<Arc<dyn AuctionProvider>> = Vec::new();

// Check for real APS provider configuration
Expand All @@ -550,11 +557,11 @@ pub fn register_providers(settings: &Settings) -> Vec<Arc<dyn AuctionProvider>>
log::debug!("APS integration config found but is disabled");
}
Err(e) => {
log::error!("Failed to load APS configuration: {:?}", e);
return Err(e);
}
}

providers
Ok(providers)
}

// ============================================================================
Expand Down
38 changes: 21 additions & 17 deletions crates/common/src/integrations/datadome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,17 +461,13 @@ impl IntegrationAttributeRewriter for DataDomeIntegration {
}
}

fn build(settings: &Settings) -> Option<Arc<DataDomeIntegration>> {
let config = match settings.integration_config::<DataDomeConfig>(DATADOME_INTEGRATION_ID) {
Ok(Some(config)) => config,
Ok(None) => {
log::debug!("[datadome] Integration disabled or not configured");
return None;
}
Err(err) => {
log::error!("[datadome] Failed to load integration config: {err:?}");
return None;
}
fn build(
settings: &Settings,
) -> Result<Option<Arc<DataDomeIntegration>>, Report<TrustedServerError>> {
let Some(config) = settings.integration_config::<DataDomeConfig>(DATADOME_INTEGRATION_ID)?
else {
log::debug!("[datadome] Integration disabled or not configured");
return Ok(None);
};

log::info!(
Expand All @@ -480,20 +476,28 @@ fn build(settings: &Settings) -> Option<Arc<DataDomeIntegration>> {
config.rewrite_sdk
);

Some(DataDomeIntegration::new(config))
Ok(Some(DataDomeIntegration::new(config)))
}

/// Register the `DataDome` integration with Trusted Server.
#[must_use]
pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
let integration = build(settings)?;
///
/// # Errors
///
/// Returns an error when the `DataDome` integration is enabled with invalid
/// configuration.
pub fn register(
settings: &Settings,
) -> Result<Option<IntegrationRegistration>, Report<TrustedServerError>> {
let Some(integration) = build(settings)? else {
return Ok(None);
};

Some(
Ok(Some(
IntegrationRegistration::builder(DATADOME_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration)
.build(),
)
))
}

#[cfg(test)]
Expand Down
38 changes: 23 additions & 15 deletions crates/common/src/integrations/didomi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,28 +153,36 @@ impl DidomiIntegration {
}
}

fn build(settings: &Settings) -> Option<Arc<DidomiIntegration>> {
let config = match settings.integration_config::<DidomiIntegrationConfig>(DIDOMI_INTEGRATION_ID)
{
Ok(Some(config)) => Arc::new(config),
Ok(None) => return None,
Err(err) => {
log::error!("Failed to load Didomi integration config: {err:?}");
return None;
}
fn build(
settings: &Settings,
) -> Result<Option<Arc<DidomiIntegration>>, Report<TrustedServerError>> {
let Some(config) =
settings.integration_config::<DidomiIntegrationConfig>(DIDOMI_INTEGRATION_ID)?
else {
return Ok(None);
};
Some(DidomiIntegration::new(config))

Ok(Some(DidomiIntegration::new(Arc::new(config))))
}

/// Register the Didomi consent notice integration when enabled.
#[must_use]
pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
let integration = build(settings)?;
Some(
///
/// # Errors
///
/// Returns an error when the Didomi integration is enabled with invalid
/// configuration.
pub fn register(
settings: &Settings,
) -> Result<Option<IntegrationRegistration>, Report<TrustedServerError>> {
let Some(integration) = build(settings)? else {
return Ok(None);
};

Ok(Some(
IntegrationRegistration::builder(DIDOMI_INTEGRATION_ID)
.with_proxy(integration)
.build(),
)
))
}

#[async_trait(?Send)]
Expand Down
Loading
Loading