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

### Changed

- **Breaking:** Known `[integrations.*]` tables, `[tester_cookie]`, and `[tinybird]` now reject unknown fields, including in disabled integration blocks. Integration parse errors name the offending key. `ts config validate` and `ts config push` also reject unknown integration IDs and list the supported IDs; runtime startup warns and ignores unknown IDs for compatibility with stored configs. Before deploying the new binary, use the new CLI to run `ts config validate` on the current config, correct the reported fields and IDs, then push the cleaned config while the old binary is still running. On Fastly, wait for the config-store propagation window to elapse before activating the new binary; a successful push does not mean every point of presence (POP) has the new config. Stored unknown fields can otherwise prevent startup. Intentionally open payload maps remain supported. Lockr uses `app_id`; remove unsupported `organization_id` and `project_id` fields.

- **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header.
- **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias.
- The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave.
Expand Down
52 changes: 52 additions & 0 deletions crates/trusted-server-cli/tests/config_env_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,3 +468,55 @@ fn pushed_envelope_sha_is_stable_for_one_context_key_allowlist() {
);
}
}

#[test]
fn config_validate_and_push_reject_unknown_integration_ids_and_fields() {
let cases = [
("prebdi", "enabled", true, "prebdi"),
("gpt", "rewrite_scripts", false, "rewrite_scripts"),
("gpt", "enable", false, "enable"),
("osano", "made_up_key", false, "made_up_key"),
];
for (id, field, field_value, expected) in cases {
for command in ["validate", "push"] {
let project = migrated_project();
let mut document = fs::read_to_string(&project.config_path)
.expect("should read valid config")
.parse::<DocumentMut>()
.expect("should parse valid config");
document["integrations"][id] = toml_edit::table();
document["integrations"][id][field] = value(field_value);
fs::write(&project.config_path, document.to_string())
.expect("should write invalid integration config");
let mut process = Command::new(env!("CARGO_BIN_EXE_ts"));
process
.args(["config", command, "--manifest"])
.arg(&project.manifest_path)
.arg("--app-config")
.arg(&project.config_path)
.current_dir(project.directory.path());
if command == "push" {
process.args(["--adapter", "axum", "--yes", "--no-diff"]);
}

let output = process.output().expect("should run config command");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"should reject {id}.{field} during {command}: {stderr}"
);
assert!(
stderr.contains(expected),
"should name {expected} during {command}: {stderr}"
);
assert!(
!project
.directory
.path()
.join(".edgezero/local-config-trusted_server_config.json")
.exists(),
"should reject invalid configuration before writing a local blob"
);
}
}
}
197 changes: 196 additions & 1 deletion crates/trusted-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ use crate::integrations::{
use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings};

const DEPLOY_VALIDATION_FIELD: &str = "trusted_server";
#[cfg(test)]
const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[
#[cfg(feature = "test-utils")]
crate::test_support::async_html_finalization::ID,
Expand Down Expand Up @@ -249,6 +248,16 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig {
/// Returns [`TrustedServerError`] when non-secret configuration or a secret key
/// reference is invalid.
pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report<TrustedServerError>> {
let unknown_ids = unknown_integration_ids(settings);
if !unknown_ids.is_empty() {
return Err(Report::new(TrustedServerError::Configuration {
message: format!(
"Unknown integration IDs: {}. Known integration IDs: {}",
unknown_ids.join(", "),
DEPLOY_VALIDATED_INTEGRATION_IDS.join(", ")
),
}));
}
validate_secret_key_references(settings)?;
validate_non_secret_deploy_placeholders(settings)?;
validate_js_asset_proxy_config(settings)?;
Expand All @@ -272,6 +281,11 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report<Tr
pub fn validate_settings_for_runtime(
settings: &Settings,
) -> Result<(), Report<TrustedServerError>> {
for id in unknown_integration_ids(settings) {
log::warn!(
"Unknown integration ID '{id}' is ignored; check the integration name or remove its configuration"
Comment thread
prk-Jr marked this conversation as resolved.
Resolved
);
}
settings.reject_placeholder_secrets()?;
validate_js_asset_proxy_config(settings)?;
settings.validate_admin_handler_passwords()?;
Expand All @@ -281,6 +295,17 @@ pub fn validate_settings_for_runtime(
Ok(())
}

fn unknown_integration_ids(settings: &Settings) -> Vec<&str> {
let mut ids = settings
.integrations
.keys()
.map(String::as_str)
.filter(|id| !DEPLOY_VALIDATED_INTEGRATION_IDS.contains(id))
.collect::<Vec<_>>();
ids.sort_unstable();
ids
}

fn validate_js_asset_proxy_config(settings: &Settings) -> Result<(), Report<TrustedServerError>> {
let Some(raw_config) = settings.integrations.get(JS_ASSET_PROXY_INTEGRATION_ID) else {
return Ok(());
Expand Down Expand Up @@ -1504,6 +1529,176 @@ password = "production-admin-password-32-bytes"
.expect("test config with typed Prebid bundle build table should validate");
}

#[test]
fn deploy_validation_rejects_unknown_integration_id() {
let mut settings = valid_settings();
settings
.integrations
.insert("prebdi".to_owned(), serde_json::json!({ "enabled": false }));

let error = validate_settings_for_deploy(&settings)
.expect_err("should reject an unknown integration ID even when disabled");
let message = error.to_string();
assert!(
message.contains("prebdi"),
"should name the unknown ID: {message}"
);
for id in DEPLOY_VALIDATED_INTEGRATION_IDS {
assert!(message.contains(id), "should list known ID {id}: {message}");
}
}

#[test]
fn runtime_validation_warns_about_unknown_integration_id() {
let mut settings = valid_settings();
settings
.integrations
.insert("prebdi".to_owned(), serde_json::json!({ "enabled": true }));
let capture = crate::test_support::logging::WarningCapture::start();

validate_settings_for_runtime(&settings)
.expect("should keep starting with an unknown stored integration ID");

let warnings = capture.take();
assert!(
warnings
.iter()
.any(|warning| warning.message.contains("prebdi")),
"should warn about the ignored unknown ID"
);
}

#[test]
fn deploy_validation_rejects_unknown_integration_fields() {
let cases = [
(
"adserver_mock",
serde_json::json!({ "endpoint": "https://mediator.example.com/mediate" }),
),
("aps", serde_json::json!({})),
("datadome", serde_json::json!({})),
("didomi", serde_json::json!({})),
(
"google_tag_manager",
serde_json::json!({ "container_id": "GTM-ABC1234" }),
),
("gpt", serde_json::json!({})),
("gpt_diagnostics", serde_json::json!({})),
(
"js_asset_proxy",
serde_json::json!({ "assets": [{ "path": "/assets/vendor.js", "origin_url": "https://cdn.example.com/vendor.js" }] }),
),
("lockr", serde_json::json!({ "app_id": "example-app" })),
("nextjs", serde_json::json!({})),
("osano", serde_json::json!({})),
(
"permutive",
serde_json::json!({ "organization_id": "example", "workspace_id": "example" }),
),
(
"prebid",
serde_json::json!({ "external_bundle_url": "https://cdn.example.com/prebid.js" }),
),
("sourcepoint", serde_json::json!({})),
(
"testlight",
serde_json::json!({ "endpoint": "https://testlight.example.com/auction" }),
),
];
let mut accepted = Vec::new();
for (id, base) in cases {
for enabled in [true, false] {
let mut config = base.clone();
config["enabled"] = serde_json::json!(enabled);
let mut settings = valid_settings();
settings.integrations.insert(id.to_owned(), config.clone());
validate_settings_for_deploy(&settings)
.unwrap_or_else(|error| panic!("should accept baseline {id}: {error:?}"));

config["made_up_key"] = serde_json::json!("x");
settings.integrations.insert(id.to_owned(), config);
match validate_settings_for_deploy(&settings) {
Ok(()) => accepted.push(format!("{id} (enabled = {enabled})")),
Err(error) => {
let message = error.to_string();
assert!(
message.contains("made_up_key"),
"should name unknown field for {id}: {message}"
);
assert!(
message.contains(id),
"should name integration {id}: {message}"
);
}
}
}
}
assert!(
accepted.is_empty(),
"should reject unknown fields in all integrations: {accepted:?}"
);
}

#[test]
fn deploy_validation_rejects_unknown_fields_in_minimal_disabled_integrations() {
for id in DEPLOY_VALIDATED_INTEGRATION_IDS {
let mut settings = valid_settings();
settings
.integrations
.insert((*id).to_owned(), serde_json::json!({ "enabled": false }));
validate_settings_for_deploy(&settings)
.unwrap_or_else(|error| panic!("should accept minimal disabled {id}: {error:?}"));

settings.integrations.insert(
(*id).to_owned(),
serde_json::json!({ "enabled": false, "made_up_key": true }),
);
let error = validate_settings_for_deploy(&settings)
.expect_err("should reject unknown fields even with missing enabled-only fields");
assert!(
error.to_string().contains("made_up_key"),
"should name unknown field for {id}: {error:?}"
);
}
}

#[test]
fn deploy_validation_preserves_open_integration_payload_maps_and_aliases() {
let mut settings = valid_settings();
settings.integrations.insert(
"adserver_mock".to_owned(),
serde_json::json!({
"enabled": true,
"endpoint": "https://mediator.example.com/mediate",
"context_query_params": { "example_context": "example_query" }
}),
);
settings.integrations.insert(
"datadome".to_owned(),
serde_json::json!({
"enabled": true,
"client_side_configuration": { "example_option": { "custom_key": true } }
}),
);
settings.integrations.insert(
"prebid".to_owned(),
serde_json::json!({
"enabled": true,
"external_bundle_url": "https://cdn.example.com/prebid.js",
"managed_user_ids": [{ "name": "sharedId", "params": { "example_option": true } }]
}),
);
settings.integrations.insert(
"lockr".to_owned(),
serde_json::json!({
"enabled": true, "app_id": "example-app", "rewrite_sdk_host": false
}),
);

validate_settings_for_deploy(&settings)
.expect("should accept open payload maps and explicit compatibility fields");
}

#[test]
fn deploy_validation_covers_registered_integration_builders() {
let validated_ids: HashSet<&'static str> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use crate::settings::{IntegrationConfig, Settings};

/// Configuration for mock ad server integration.
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct AdServerMockConfig {
/// Whether this integration is enabled
#[serde(default = "default_enabled")]
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/didomi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const DIDOMI_DEFAULT_PREFIX: &str = "/integrations/didomi/consent";

/// Configuration for the Didomi consent notice reverse proxy.
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct DidomiIntegrationConfig {
/// Whether the integration is enabled.
#[serde(default = "default_enabled")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ pub(crate) fn rewrite_gtm_rsc_span(content: &str, record_text: bool) -> String {
}

#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct GoogleTagManagerConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/gpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const ROUTE_PREFIX: &str = "/integrations/gpt";

/// Configuration for the Google Publisher Tags integration.
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct GptConfig {
/// Enable/disable the integration.
#[serde(default = "default_enabled")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const ERROR_ORIGIN_STATUS: &str = "js-asset-origin-status";

/// Configuration for the JavaScript asset proxy integration.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct JsAssetProxyConfig {
/// Enables or disables the integration.
#[serde(default)]
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/lockr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const LOCKR_INTEGRATION_ID: &str = "lockr";

/// Configuration for Lockr integration.
#[derive(Debug, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct LockrConfig {
/// Enable/disable the integration
#[serde(default = "default_enabled")]
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/nextjs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use rsc_stream::NextJsRscStreamProcessorFactory;
use script_rewriter::NextJsNextDataRewriter;

#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct NextJsIntegrationConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/permutive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const PERMUTIVE_INTEGRATION_ID: &str = "permutive";

/// Configuration for Permutive integration.
#[derive(Debug, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct PermutiveConfig {
/// Enable/disable the integration
#[serde(default = "default_enabled")]
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/sourcepoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ static SP_MESSAGE_ORIGIN_GUARD_PATTERN: LazyLock<Regex> = LazyLock::new(|| {

/// Configuration for the Sourcepoint first-party proxy.
#[derive(Debug, Clone, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct SourcepointConfig {
/// Whether the integration is enabled.
#[serde(default = "default_enabled")]
Expand Down
1 change: 1 addition & 0 deletions crates/trusted-server-core/src/integrations/testlight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use crate::tsjs;
const TESTLIGHT_INTEGRATION_ID: &str = "testlight";

#[derive(Debug, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct TestlightConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
Expand Down
Loading
Loading