diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 3bd747f6..f56a7b8a 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -2,9 +2,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; +use validator::Validate; /// Auction orchestration configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct AuctionConfig { /// Enable the auction orchestrator @@ -23,6 +24,7 @@ pub struct AuctionConfig { /// Timeout in milliseconds #[serde(default = "default_timeout")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, /// KV store name for creative storage (deprecated: creatives are now delivered inline) @@ -79,3 +81,29 @@ impl AuctionConfig { self.mediator.is_some() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with_timeout(timeout_ms: u32) -> AuctionConfig { + AuctionConfig { + timeout_ms, + ..AuctionConfig::default() + } + } + + #[test] + fn timeout_ms_range_is_enforced() { + for good in [1, 2000, 60000] { + config_with_timeout(good) + .validate() + .unwrap_or_else(|err| panic!("timeout {good} should be accepted: {err:?}")); + } + for bad in [0, 60001] { + config_with_timeout(bad) + .validate() + .expect_err(&format!("timeout {bad} should be rejected")); + } + } +} diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd74..a7ad6099 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -281,6 +281,82 @@ password = "production-admin-password-32-bytes" ); } + #[test] + fn deploy_validation_rejects_example_publisher_hosts() { + let mut settings = valid_settings(); + settings.publisher.domain = "example.com".to_string(); + settings.publisher.cookie_domain = ".example.com".to_string(); + settings.publisher.origin_url = "https://origin.example.com".to_string(); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject unedited example publisher hosts"); + let text = format!("{err:?}"); + + assert!( + text.contains("publisher.domain") + && text.contains("publisher.cookie_domain") + && text.contains("publisher.origin_url"), + "should flag all three example publisher placeholders: {err:?}" + ); + } + + #[test] + fn deploy_validation_rejects_placeholder_request_signing_store_ids() { + let mut settings = valid_settings(); + settings.request_signing = Some(crate::settings::RequestSigning { + enabled: true, + config_store_id: "".to_string(), + secret_store_id: "".to_string(), + }); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject placeholder request-signing store ids when enabled"); + let text = format!("{err:?}"); + + assert!( + text.contains("request_signing.config_store_id") + && text.contains("request_signing.secret_store_id"), + "should flag both request-signing store ids: {err:?}" + ); + } + + #[test] + fn deploy_validation_allows_disabled_request_signing_with_placeholder_store_ids() { + let mut settings = valid_settings(); + settings.request_signing = Some(crate::settings::RequestSigning { + enabled: false, + config_store_id: "".to_string(), + secret_store_id: "".to_string(), + }); + + validate_settings_for_deploy(&settings) + .expect("should allow placeholder store ids while request signing is disabled"); + } + + #[test] + fn deploy_validation_rejects_placeholder_aps_pub_id() { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "aps", + &serde_json::json!({ + "enabled": true, + "pub_id": "your-aps-publisher-id", + "endpoint": "https://aps.example.com/e/dtb/bid" + }), + ) + .expect("should insert APS config"); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject placeholder APS pub_id when enabled"); + + assert!( + format!("{err:?}").contains("aps"), + "should mention the APS integration: {err:?}" + ); + } + #[test] fn deploy_validation_rejects_external_prebid_bundle_without_proxy_allowed_domains() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index d8b2a325..7028f941 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -47,6 +47,7 @@ pub struct AdServerMockConfig { /// Timeout in milliseconds #[serde(default = "default_timeout_ms")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, /// Optional price floor (minimum acceptable CPM) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index edee6133..4b3f7fb7 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; use std::collections::HashMap; use std::time::Duration; -use validator::Validate; +use validator::{Validate, ValidationError}; use crate::auction::provider::AuctionProvider; use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, MediaType}; @@ -201,6 +201,7 @@ pub struct ApsConfig { /// APS publisher ID (accepts both string and integer from config) #[serde(deserialize_with = "deserialize_pub_id")] + #[validate(length(min = 1), custom(function = validate_aps_pub_id))] pub pub_id: String, /// APS API endpoint @@ -210,6 +211,7 @@ pub struct ApsConfig { /// Timeout in milliseconds #[serde(default = "default_timeout_ms")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, } @@ -285,6 +287,26 @@ impl Default for ApsConfig { } } +/// Validator for [`ApsConfig::pub_id`]: rejects the known template placeholder +/// publisher ID. Non-emptiness is enforced by the built-in `length` validator; +/// this runs only when APS is enabled, because integration configs validate +/// lazily via `get_typed`. +fn validate_aps_pub_id(pub_id: &str) -> Result<(), ValidationError> { + if ApsConfig::PUB_ID_PLACEHOLDERS + .iter() + .any(|placeholder| placeholder.eq_ignore_ascii_case(pub_id.trim())) + { + return Err(ValidationError::new("aps_pub_id_placeholder")); + } + Ok(()) +} + +impl ApsConfig { + /// Reserved example `pub_id` values from the config template that must not + /// be deployed while APS is enabled. + pub const PUB_ID_PLACEHOLDERS: &[&str] = &["your-aps-publisher-id"]; +} + impl IntegrationConfig for ApsConfig { fn is_enabled(&self) -> bool { self.enabled diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 58a429e7..ba0a99ae 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -89,7 +89,13 @@ pub struct GoogleTagManagerConfig { #[serde(default = "default_enabled")] pub enabled: bool, /// GTM Container ID (e.g., "GTM-XXXXXX"). - #[validate(length(min = 1, max = 50), custom(function = "validate_container_id"))] + #[validate( + length(min = 1, max = 50), + regex( + path = *GTM_CONTAINER_ID_PATTERN, + message = "container_id must match format GTM-XXXXXX where X is alphanumeric" + ) + )] pub container_id: String, /// Upstream URL for GTM (defaults to ). #[serde(default = "default_upstream")] @@ -128,16 +134,6 @@ fn default_max_beacon_body_size() -> usize { 65536 // 64KB - prevents memory pressure from oversized payloads } -fn validate_container_id(container_id: &str) -> Result<(), validator::ValidationError> { - if GTM_CONTAINER_ID_PATTERN.is_match(container_id) { - Ok(()) - } else { - Err(validator::ValidationError::new( - "container_id must match format GTM-XXXXXX where X is alphanumeric", - )) - } -} - /// GTM domain markers the script rewriter looks for. Kept in one place so the /// boundary-safe prefix check in [`might_contain_gtm_prefix`] and the full-match /// check in [`GoogleTagManagerIntegration::rewrite`] cannot drift apart. @@ -677,6 +673,39 @@ mod tests { use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; + #[test] + fn container_id_validation_matches_gtm_pattern() { + use validator::Validate as _; + + let config = |id: &str| -> GoogleTagManagerConfig { + serde_json::from_value(serde_json::json!({ "container_id": id })) + .expect("should deserialize GTM config") + }; + + // Well-formed container ids pass. + for good in ["GTM-ABCD", "GTM-ABCD1234", "GTM-A1B2C3D4E5"] { + config(good) + .validate() + .unwrap_or_else(|err| panic!("valid container id {good:?} should pass: {err:?}")); + } + + // Malformed ids are rejected: wrong prefix, too short, lowercase, bad + // chars, empty. + for bad in [ + "ABCD1234", + "GTM-abc", + "gtm-ABCD", + "GTM-AB", + "GTM_ABCD", + "GTM-ABCD!", + "", + ] { + config(bad) + .validate() + .expect_err(&format!("invalid container id {bad:?} should be rejected")); + } + } + use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; use http::Method; diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a8..3a3ab3cc 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::Duration; use async_trait::async_trait; @@ -13,6 +13,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::header::HeaderValue; use http::{header, Method, StatusCode}; +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; @@ -99,6 +100,7 @@ pub struct PrebidIntegrationConfig { #[serde(default)] pub account_id: Option, #[serde(default = "default_timeout_ms")] + #[validate(range(min = 1, max = 60000))] pub timeout_ms: u32, #[serde( default = "default_bidders", @@ -129,7 +131,10 @@ pub struct PrebidIntegrationConfig { pub external_bundle_url: Option, /// Optional hex SHA-256 of the exact external bundle bytes. #[serde(default)] - #[validate(custom(function = "validate_external_bundle_sha256"))] + #[validate(regex( + path = *EXTERNAL_BUNDLE_SHA256_PATTERN, + message = "external_bundle_sha256 must be a 64-character hex SHA-256" + ))] pub external_bundle_sha256: Option, /// Optional browser Subresource Integrity value for the first-party script. #[serde(default)] @@ -332,15 +337,10 @@ fn validate_external_bundle_url(value: &str) -> Result<(), ValidationError> { Ok(()) } -fn validate_external_bundle_sha256(value: &str) -> Result<(), ValidationError> { - if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Ok(()); - } - - let mut err = ValidationError::new("invalid_external_bundle_sha256"); - err.message = Some("external_bundle_sha256 must be a 64-character hex SHA-256".into()); - Err(err) -} +/// Exact hex SHA-256: 64 hex digits. Used by the built-in `regex` validator on +/// [`PrebidIntegrationConfig::external_bundle_sha256`]. +static EXTERNAL_BUNDLE_SHA256_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^[0-9a-fA-F]{64}$").expect("SHA-256 hex regex should compile")); #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum ExternalBundleSriAlgorithm { @@ -2238,6 +2238,39 @@ mod tests { use crate::consent::{ConsentContext, ConsentSource}; use crate::geo::GeoInfo; + + #[test] + fn external_bundle_sha256_validation_matches_hex_pattern() { + use validator::Validate as _; + + let config = |sha: &str| -> PrebidIntegrationConfig { + serde_json::from_value(serde_json::json!({ + "server_url": "https://prebid.example.com/openrtb2/auction", + "external_bundle_sha256": sha, + })) + .expect("should deserialize prebid config") + }; + + // Exactly 64 hex digits (either case) passes. + config(&"a".repeat(64)) + .validate() + .expect("64-char lowercase hex sha256 should pass"); + config("ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789") + .validate() + .expect("mixed-case 64-char hex sha256 should pass"); + + // Wrong length or non-hex characters are rejected. + for bad in [ + "a".repeat(63), + "a".repeat(65), + "g".repeat(64), + String::new(), + ] { + config(&bad) + .validate() + .expect_err(&format!("invalid sha256 {bad:?} should be rejected")); + } + } use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; use crate::integrations::{ AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a54..969c17a7 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -114,6 +114,39 @@ impl Publisher { .any(|p| p.eq_ignore_ascii_case(proxy_secret)) } + /// Reserved example publisher values copied verbatim from the config + /// template. They deserialize fine but must be replaced before deploying. + const PLACEHOLDER_DOMAINS: &[&str] = &["example.com"]; + const PLACEHOLDER_COOKIE_DOMAINS: &[&str] = &[".example.com"]; + const PLACEHOLDER_ORIGIN_URLS: &[&str] = &["https://origin.example.com"]; + + /// Returns `true` if `domain` is the unedited template placeholder + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_domain(domain: &str) -> bool { + Self::PLACEHOLDER_DOMAINS + .iter() + .any(|p| p.eq_ignore_ascii_case(domain.trim())) + } + + /// Returns `true` if `cookie_domain` is the unedited template placeholder + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_cookie_domain(cookie_domain: &str) -> bool { + Self::PLACEHOLDER_COOKIE_DOMAINS + .iter() + .any(|p| p.eq_ignore_ascii_case(cookie_domain.trim())) + } + + /// Returns `true` if `origin_url` is the unedited template placeholder + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_origin_url(origin_url: &str) -> bool { + Self::PLACEHOLDER_ORIGIN_URLS + .iter() + .any(|p| p.eq_ignore_ascii_case(origin_url.trim())) + } + /// Extracts the host (including port if present) from the `origin_url`. /// /// # Examples @@ -648,6 +681,26 @@ pub struct RequestSigning { pub secret_store_id: String, } +impl RequestSigning { + /// Reserved example store-id values from the config template, plus the + /// empty string, that must not be deployed while request signing is enabled. + pub const STORE_ID_PLACEHOLDERS: &[&str] = &[ + "", + "", + ]; + + /// Returns `true` if `store_id` is empty or a known template placeholder + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_store_id(store_id: &str) -> bool { + let store_id = store_id.trim(); + store_id.is_empty() + || Self::STORE_ID_PLACEHOLDERS + .iter() + .any(|p| p.eq_ignore_ascii_case(store_id)) + } +} + fn default_request_signing_enabled() -> bool { false } @@ -1947,6 +2000,7 @@ pub struct Settings { #[validate(nested)] pub rewrite: Rewrite, #[serde(default)] + #[validate(nested)] pub auction: AuctionConfig, #[serde(default)] pub consent: ConsentConfig, @@ -2133,6 +2187,25 @@ impl Settings { insecure_fields.push(format!("handlers[{}].password", handler.path)); } } + if Publisher::is_placeholder_domain(&self.publisher.domain) { + insecure_fields.push("publisher.domain".to_owned()); + } + if Publisher::is_placeholder_cookie_domain(&self.publisher.cookie_domain) { + insecure_fields.push("publisher.cookie_domain".to_owned()); + } + if Publisher::is_placeholder_origin_url(&self.publisher.origin_url) { + insecure_fields.push("publisher.origin_url".to_owned()); + } + if let Some(request_signing) = &self.request_signing { + if request_signing.enabled { + if RequestSigning::is_placeholder_store_id(&request_signing.config_store_id) { + insecure_fields.push("request_signing.config_store_id".to_owned()); + } + if RequestSigning::is_placeholder_store_id(&request_signing.secret_store_id) { + insecure_fields.push("request_signing.secret_store_id".to_owned()); + } + } + } if insecure_fields.is_empty() { return Ok(()); diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0951b781..ec9fbb34 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -1,154 +1,373 @@ +# ============================================================================= +# Trusted Server — application configuration template +# ============================================================================= +# +# This is the source-controlled starting point for an operator-owned +# `trusted-server.toml`. Copy it (`ts config init`), fill in the required +# values, and push it (`ts config push`) as an EdgeZero app-config blob. +# +# Only three sections are REQUIRED for the server to start and pass validation: +# 1. [[handlers]] covering /_ts/admin (admin authentication) +# 2. [publisher] (domain + origin) +# 3. [ec] passphrase (Edge Cookie identity secret) +# +# Everything below those is OPTIONAL. Most optional blocks are commented out — +# uncomment and edit one to enable it — but a few integrations are kept as active +# `enabled = false` stubs (see the Integrations section for why). All example +# hosts use `example.com`; replace them with your real endpoints in your private +# config, not in this template. +# +# `TRUSTED_SERVER__` env vars can override values when the `ts` CLI builds and +# validates this config for push (e.g. TRUSTED_SERVER__PUBLISHER__DOMAIN=...; +# nested keys use `__`). The deployed runtime reads the pushed config blob, so +# these env vars do not change live behavior on their own. +# ============================================================================= + + +# ----------------------------------------------------------------------------- +# REQUIRED — Admin authentication +# ----------------------------------------------------------------------------- +# HTTP Basic-auth handler(s). At least one handler whose `path` regex covers +# the /_ts/admin endpoints is mandatory; startup fails without it. Each handler +# needs a non-placeholder username/password (deploy validation rejects the +# sample password below). [[handlers]] +# Regex matched against the request path. This one guards the admin surface. path = "^/_ts/admin" username = "admin" password = "replace-with-admin-password-32-bytes" +# You can add more handlers to basic-auth-protect other path prefixes. The +# sample password below is a known placeholder that deploy validation rejects, +# so it forces a real secret before push: +# [[handlers]] +# path = "^/secure" +# username = "user" +# password = "replace-with-admin-password" + + +# ----------------------------------------------------------------------------- +# REQUIRED — Publisher / origin +# ----------------------------------------------------------------------------- [publisher] +# Public domain Trusted Server is fronting. domain = "example.com" +# Cookie scope for first-party identity cookies (leading dot = include subdomains). cookie_domain = ".example.com" +# Upstream origin to proxy publisher content from. No trailing slash. origin_url = "https://origin.example.com" -# Optional: override outbound Host header while connecting to origin_url. -# origin_host_header_override = "www.example.com" +# HMAC secret for signing first-party proxy URLs. Replace before deploying. proxy_secret = "change-me-proxy-secret" +# Optional: override the outbound Host header sent to origin_url. +# origin_host_header_override = "www.example.com" +# Optional: max bytes buffered when a response is post-processed in full (HTML +# rewriting/injection) instead of streamed. Default 16 MiB; larger responses +# return 502. Raise for deployments serving very large publisher pages. +# max_buffered_body_bytes = 16777216 # 16 MiB + +# ----------------------------------------------------------------------------- +# REQUIRED — Edge Cookie (EC) identity +# ----------------------------------------------------------------------------- [ec] +# Secret used to derive EC identifiers. Must be >= 32 chars and non-placeholder +# in production (deploy validation rejects known placeholders). passphrase = "trusted-server-placeholder-secret" +# KV store that persists EC identity state. This is the physical store name +# bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's +# logical KV id is `trusted_server_kv`. ec_store = "ec_identity_store" +# Max concurrent partner pull-sync requests. pull_sync_concurrency = 3 -# cluster_trust_threshold = 10 -# cluster_recheck_secs = 3600 +# Optional cluster-heuristic tuning (defaults shown): +# cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users +# cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds -# Example partner configuration. Replace the token before validating/pushing. +# Optional identity partners (SSP/DSP/identity vendors). Each needs a real, +# non-placeholder api_token (>= 32 bytes) at deploy. Configure real partners via +# private config, not this template. # [[ec.partners]] # name = "Example Partner" # source_domain = "partner.example.com" -# openrtb_atype = 3 -# bidstream_enabled = true +# openrtb_atype = 3 # OpenRTB source.atype (default 3) +# bidstream_enabled = true # include this partner's UIDs in auction user.eids # api_token = "replace-with-partner-api-token-32-bytes-minimum" -# batch_rate_limit = 60 -# pull_sync_enabled = false +# batch_rate_limit = 60 # max batch-sync requests/min (default 60) +# pull_sync_enabled = false # default false + + +# ============================================================================= +# OPTIONAL — Core features (disabled/omitted by default) +# ============================================================================= -# Custom headers to include in every response. +# Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] # X-Robots-Tag = "noindex" -[request_signing] -enabled = false -config_store_id = "app_config" -secret_store_id = "secrets" +# Sign outbound OpenRTB/API requests. When present, both ids are required. These +# are platform management-API store IDs used for key-rotation WRITES; the runtime +# reads keys from fixed store names (`jwks_store` / `signing_keys`), not these. +# [request_signing] +# enabled = false +# config_store_id = "" +# secret_store_id = "" -[integrations.prebid] -enabled = false -server_url = "https://prebid.example.com/openrtb2/auction" -timeout_ms = 1000 -bidders = [] -debug = false -client_side_bidders = [] -# Runtime bundle metadata. Set these after running `ts prebid bundle` and uploading the bundle. +# First-party HTML/CSS rewriting controls. +# [rewrite] +# Domains left as-is (not proxied/rewritten). Supports "*.example.com" wildcards. +# exclude_domains = ["*.cdn.example.com"] + +# Tester-cookie endpoints: GET /_ts/set-tester sets ts-tester=true and +# GET /_ts/clear-tester clears it on publisher.cookie_domain. +# [tester_cookie] +# enabled = false + +# Consent forwarding. All values below are the defaults — uncomment to override. +# [consent] +# mode = "interpreter" # "interpreter" (decode + forward) or "proxy" (raw passthrough) +# check_expiration = true # check TCF consent freshness +# max_consent_age_days = 395 # max age before consent is treated as expired (~13 months) +# +# [consent.gdpr] +# applies_in = ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"] +# +# [consent.us_states] +# privacy_states = ["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"] +# +# [consent.us_privacy_defaults] +# notice_given = true # has the publisher shown CCPA notice? +# lspa_covered = false # is the publisher subject to LSPA? +# gpc_implies_optout = true # should Sec-GPC: 1 trigger opt-out? +# +# [consent.conflict_resolution] +# mode = "restrictive" # "restrictive" | "newest" | "permissive" +# freshness_threshold_days = 30 + +# Proxy behavior and first-party asset routing. +# [proxy] +# Verify TLS certs when proxying to HTTPS origins (default true; false only for +# local self-signed dev). +# certificate_check = true +# Allowlist for first-party proxy redirect destinations (SSRF guard). Supports +# exact ("example.com") and wildcard ("*.example.com", also matches apex). +# REQUIRED to include the Prebid external_bundle_url host when Prebid is enabled. +# allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] +# +# Route first-party asset paths to a different backend origin. Longest matching +# prefix wins; only GET/HEAD participate; query string is preserved. +# [[proxy.asset_routes]] +# prefix = "/.image/" +# origin_url = "https://assets.example.com" +# Optional path rewrite before sending upstream (the prefix must match what the +# pattern expects — here both use `/.image/`): +# path_pattern = "^/\\.image/(.*)/[^/]+\\.([^/.]+)$" +# target_path = "/image/upload/$1.$2" +# +# Optional S3 SigV4 auth for a private origin: +# [proxy.asset_routes.auth] +# type = "s3_sigv4" +# region = "us-east-1" +# origin_query = "strip" +# secret_store = "s3-auth" +# access_key_id = "access_key_id" +# secret_access_key = "secret_access_key" +# +# Optional Fastly Image Optimizer for the route (references a profile_set below): +# [proxy.asset_routes.image_optimizer] +# enabled = true +# region = "us_east" +# profile_set = "default_images" + +# Reusable Fastly Image Optimizer profile sets referenced by asset routes. +# Supported IO params are a strict subset: quality, resize-filter, format, +# width, height, crop. Keep production profile tables in private config. +# [image_optimizer.profile_sets.default_images] +# base_params = "quality=70&resize-filter=bicubic" +# default_profile = "default" +# unknown_profile = "use_default" # "use_default" or "reject" +# profile_param = "profile" +# +# [image_optimizer.profile_sets.default_images.profiles] +# default = "width=1920" +# thumbnail = "width=150&crop=1:1,smart" +# medium = "format=auto&width=828" + +# Server-side auction. Provider/mediator names must match enabled integrations. +# [auction] +# enabled = false +# providers = ["prebid"] +# mediator = "adserver_mock" # optional mediator integration +# timeout_ms = 2000 +# Context keys the JS client may forward into auction requests (allowlist; +# empty blocks all). +# allowed_context_keys = ["permutive_segments"] + +# Server-side ad slot templates + creative-opportunity auction. +# [creative_opportunities] +# gam_network_id = "123456789" +# price_granularity = "dense" +# FCP is not affected by this value — body content above has already +# streamed and painted before the hold begins. What this caps is the slip on +# DOMContentLoaded and window.load. 500 ms is the recommended default; raise +# only if your SSPs need more headroom and analytics confirm the DCL slip is OK. +# auction_timeout_ms = 500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS +# +# Slot templates. Add one block per slot, or override the whole array via +# TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","page_patterns":[...],"formats":[...]}]' +# [[creative_opportunities.slot]] +# id = "leaderboard" +# gam_unit_path = "/123456789/leaderboard" +# div_id = "div-gpt-ad-leaderboard" +# page_patterns = ["/"] # glob syntax (not regex); "/" matches only the homepage +# formats = [{ width = 728, height = 90 }] + +# Direct Tinybird auction telemetry (all off / empty by default). +# [tinybird] +# enabled = false + +# Debug endpoints (all default false — never enable in production). +# [debug] +# Exposes GET /_ts/debug/ja4 returning TLS/JA4 fingerprint details. Disable +# after investigation; it reflects data browser JS cannot normally read. +# ja4_endpoint_enabled = false + + +# ============================================================================= +# OPTIONAL — Integrations +# ============================================================================= +# Every integration is off by default. Most are fully commented out; uncomment a +# block and set `enabled = true` to activate it. Four (gpt, didomi, datadome, +# google_tag_manager) are kept as active `enabled = false` stubs so the `ts audit` +# CLI can flip them in place — leave those sections present. Integrations whose +# `enabled` defaults to true (prebid, permutive, lockr, ...) still stay OFF while +# their section is commented out. Required fields are noted per block. +# ============================================================================= + +# Prebid Server-side auction + first-party Prebid.js bundle. +# When enabled: `server_url` is required, and `external_bundle_url` is required +# (its host must be listed in [proxy].allowed_domains). +# [integrations.prebid] +# enabled = true +# server_url = "https://prebid.example.com/openrtb2/auction" +# timeout_ms = 1000 +# bidders = [] +# debug = false +# client_side_bidders = [] # bidders running via native Prebid.js adapters +# Runtime bundle metadata — set after running `ts prebid bundle` and uploading: # external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" # external_bundle_sha256 = "" # external_bundle_sri = "" - -[integrations.prebid.bundle] -adapters = ["rubicon"] +# Per-bidder / per-zone param overrides (canonical rule form): +# [[integrations.prebid.bid_param_override_rules]] +# when.bidder = "examplebidder" +# when.zone = "header" +# set = { placementId = "_abc" } +# +# Bundle build inputs consumed by the `ts prebid bundle` CLI (not the runtime): +# [integrations.prebid.bundle] +# adapters = ["rubicon"] # user_id_modules = ["sharedIdSystem"] -[integrations.nextjs] -enabled = false -rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] -max_combined_payload_bytes = 10485760 +# Next.js first-party rewriting for App Router / RSC payloads. +# [integrations.nextjs] +# enabled = true +# rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] +# max_combined_payload_bytes = 10485760 # 10 MiB -[integrations.testlight] -enabled = false -endpoint = "https://testlight.example.com/openrtb2/auction" -timeout_ms = 1200 -rewrite_scripts = true +# Testlight OpenRTB test integration. `endpoint` required when enabled. +# [integrations.testlight] +# enabled = true +# endpoint = "https://testlight.example.com/openrtb2/auction" +# timeout_ms = 1200 +# rewrite_scripts = true +# Didomi CMP SDK/API first-party proxy. Kept active but disabled so `ts audit` +# can flip `enabled` to true when Didomi is detected on the audited page. [integrations.didomi] enabled = false -sdk_origin = "https://sdk.example.com" -api_origin = "https://api.example.com" +# sdk_origin = "https://sdk.example.com" +# api_origin = "https://api.example.com" -[integrations.sourcepoint] -enabled = false -rewrite_sdk = true -cdn_origin = "https://cdn.example.com" -cache_ttl_seconds = 3600 +# Sourcepoint CMP first-party proxy. +# [integrations.sourcepoint] +# enabled = true +# rewrite_sdk = true +# cdn_origin = "https://cdn.example.com" +# cache_ttl_seconds = 3600 +# auth_cookie_name = "sp_auth" # optional: forward a custom authCookie upstream -[integrations.permutive] -enabled = false -organization_id = "" -workspace_id = "" -project_id = "" -api_endpoint = "https://api.example.com" -secure_signals_endpoint = "https://secure-signals.example.com" +# Osano consent management (proxy toggle only). +# [integrations.osano] +# enabled = true -[integrations.lockr] -enabled = false -app_id = "" -api_endpoint = "https://identity.example.com" -sdk_url = "https://identity.example.com/trusted-server.js" -cache_ttl_seconds = 3600 -rewrite_sdk = true +# Permutive DMP. `organization_id` and `workspace_id` required when enabled. +# [integrations.permutive] +# enabled = true +# organization_id = "" +# workspace_id = "" +# project_id = "" +# api_endpoint = "https://api.example.com" +# secure_signals_endpoint = "https://secure-signals.example.com" +# lockr identity SDK. `app_id` required when enabled. +# [integrations.lockr] +# enabled = true +# app_id = "" +# api_endpoint = "https://identity.example.com" +# sdk_url = "https://identity.example.com/trusted-server.js" +# cache_ttl_seconds = 3600 +# rewrite_sdk = true + +# DataDome bot protection. Proxies tags.js + signal-collection API first-party. +# Kept active but disabled so `ts audit` can flip `enabled` when detected. [integrations.datadome] enabled = false -sdk_origin = "https://sdk.example.com" -api_origin = "https://api.example.com" -cache_ttl_seconds = 3600 -rewrite_sdk = true +# sdk_origin = "https://sdk.example.com" +# api_origin = "https://api.example.com" +# cache_ttl_seconds = 3600 +# rewrite_sdk = true +# Server-side Protection API validation (fails open on timeout/error): +# enable_protection = false +# server_side_key_secret_store = "ts_secrets" +# server_side_key_secret_name = "datadome_server_side_key" +# protection_api_origin = "https://api.example.com" +# timeout_ms = 1500 +# protection_excluded_methods = ["OPTIONS"] +# Client-side tag auto-injection (emits only when client_side_key is non-empty): +# client_side_key = "" +# inject_client_side_tag = true +# client_side_tag_url = "/integrations/datadome/tags.js" +# Google Publisher Tag (GPT) first-party proxy. Kept active but disabled so +# `ts audit` can flip `enabled` to true when GPT is detected. [integrations.gpt] enabled = false -script_url = "https://ads.example.com/gpt.js" -cache_ttl_seconds = 3600 -rewrite_script = true - -[proxy] -# certificate_check = true -# Required for integrations.prebid.external_bundle_url and first-party proxy redirects. -# allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] - -[auction] -enabled = false -providers = [] -timeout_ms = 2000 -allowed_context_keys = [] +# script_url = "https://ads.example.com/gpt.js" +# cache_ttl_seconds = 3600 +# rewrite_script = true -[integrations.aps] -enabled = false -pub_id = "your-aps-publisher-id" -endpoint = "https://aps.example.com/e/dtb/bid" -timeout_ms = 1000 +# Amazon Publisher Services (APS/TAM). `pub_id` required when enabled. +# [integrations.aps] +# enabled = true +# pub_id = "your-aps-publisher-id" +# endpoint = "https://aps.example.com/e/dtb/bid" +# timeout_ms = 1000 +# Google Tag Manager first-party proxy. Kept active but disabled so `ts audit` +# can fill container_id and flip `enabled` when GTM is detected. `container_id` +# is required when this integration is actually enabled. [integrations.google_tag_manager] enabled = false -container_id = "GTM-EXAMPLE" -upstream_url = "https://tags.example.com" +# Invalid placeholder on purpose: enabling GTM without a real GTM-XXXXXX id +# fails validation. `ts audit` overwrites this when it detects a real container. +container_id = "GTM-REPLACE-ME" +# upstream_url = "https://tags.example.com" -[integrations.adserver_mock] -enabled = false -endpoint = "https://adserver.example.com/mediate" -timeout_ms = 1000 - -[integrations.adserver_mock.context_query_params] -example_segments = "segments" - -[debug] -ja4_endpoint_enabled = false - -[creative_opportunities] -gam_network_id = "123456789" -# FCP is not affected by this value — body content above has already -# streamed and painted before the hold begins. What this caps is the slip on -# DOMContentLoaded and window.load. Worst case: a cache-hit page where origin -# drains in <50 ms but the auction runs to the limit. 500 ms is the recommended -# default; raise only if your SSPs need more headroom and your analytics confirm -# the DCL slip is acceptable. -auction_timeout_ms = 500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS -price_granularity = "dense" - -# No slot templates are enabled in the checked-in default config. Add -# `[[creative_opportunities.slot]]` entries via private config or override the -# entire array via: -# TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' +# Mock ad server used for auction mediation in dev/testing. +# [integrations.adserver_mock] +# enabled = true +# endpoint = "https://adserver.example.com/mediate" +# timeout_ms = 1000 +# Map auction context keys to mediation URL query params: +# [integrations.adserver_mock.context_query_params] +# example_segments = "segments"