diff --git a/crates/trusted-server-cli/src/commands/audit/generate/analyzer.rs b/crates/trusted-server-cli/src/commands/audit/generate/analyzer.rs index f1255a21f..8d9ca7137 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/analyzer.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/analyzer.rs @@ -11,6 +11,8 @@ use crate::commands::audit::generate::{ }; use crate::error::{CliResult, report_error}; +const LOCKR_APP_ID_SELECTOR: &str = "meta[name=\"lockr-signin-app_id\"]"; + static GTM_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\bGTM-[A-Z0-9]{4,20}\b").expect("should compile GTM regex")); static GPT_INLINE_REGEX: LazyLock = LazyLock::new(|| { @@ -65,6 +67,26 @@ pub(crate) fn analyze_collected_page(collected: &CollectedPage) -> CliResult CliResult"# + ), + script_tags: Vec::new(), + network_requests: Vec::new(), + gpt_slots: Vec::new(), + links: Vec::new(), + sitemap_locs: Vec::new(), + warnings: vec!["existing warning".to_string()], + }; + + let artifact = + analyze_collected_page(&collected).expect("should analyze collected page"); + + assert_eq!( + artifact.lockr_app_id.as_deref(), + Some("first-app-id"), + "should retain the first nonempty normalized ID" + ); + let mut expected_warnings = vec!["existing warning".to_string()]; + if conflicting { + expected_warnings.push( + "page carries more than one distinct lockr app ID; the draft uses the first" + .to_string(), + ); + } + assert_eq!( + artifact.warnings, expected_warnings, + "should preserve existing warnings and warn once only for distinct IDs without echoing them" + ); + } + } + #[test] fn analyze_collected_page_uses_html_title_when_browser_title_absent() { let collected = CollectedPage { @@ -559,6 +622,7 @@ mod tests { "https://tags.example.com/gtm.js?id=GTM-ABC123&l=dataLayer", ] { let artifact = AuditArtifact { + lockr_app_id: None, audited_url: "https://example.com".to_string(), page_title: None, js_asset_count: 0, @@ -600,6 +664,7 @@ mod tests { let evidence = format!("https://tags.example.com/gtm.js?id={container_id}&l=dataLayer"); let mut artifact = AuditArtifact { + lockr_app_id: None, audited_url: "https://publisher.example.com".to_string(), page_title: None, js_asset_count: 0, @@ -648,6 +713,7 @@ mod tests { ("https://tags.example.com/gtm.js?id=GTM-ABC", "GTM-BBB222"), ] { let artifact = AuditArtifact { + lockr_app_id: None, audited_url: "https://publisher.example.com".to_string(), page_title: None, js_asset_count: 1, @@ -677,6 +743,7 @@ mod tests { #[test] fn extract_gtm_container_id_reads_query_parameter_urls() { let artifact = AuditArtifact { + lockr_app_id: None, audited_url: "https://publisher.example".to_string(), page_title: None, js_asset_count: 1, @@ -703,6 +770,7 @@ mod tests { #[test] fn artifact_serialization_uses_expected_shape() { let artifact = AuditArtifact { + lockr_app_id: None, audited_url: "https://publisher.example".to_string(), page_title: None, js_asset_count: 1, diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index 85a380716..eaabe0b3f 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -100,6 +100,9 @@ pub(crate) struct AuditArtifact { pub(crate) page_title: Option, pub(crate) js_asset_count: usize, pub(crate) third_party_asset_count: usize, + /// Public application ID discovered in lockr page metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) lockr_app_id: Option, pub(crate) detected_integrations: Vec, pub(crate) assets: Vec, pub(crate) warnings: Vec, @@ -453,6 +456,17 @@ fn build_draft_config_with_generator( } } + if let Some(app_id) = &artifact.lockr_app_id { + draft = draft.replacen( + "# [integrations.lockr]\n# enabled = true\n# app_id = \"your-lockr-app-id\"", + &format!( + "# Enable lockr on staging and verify SDK/API behavior before production use.\n[integrations.lockr]\nenabled = false\napp_id = {}", + toml_string(app_id), + ), + 1, + ); + } + for integration in detected { if !matches!(integration, "gpt" | "datadome" | "google_tag_manager") { manual_review.push(integration); @@ -2061,6 +2075,21 @@ mod tests { } } + fn collected_lockr_page(metadata: &str, with_script: bool) -> CollectedPage { + let mut page = collected_page(); + page.html = format!("{metadata}"); + page.script_tags = if with_script { + vec![CollectedScriptTag { + src: None, + inline_text: Some("window.lockr = {};".to_string()), + }] + } else { + Vec::new() + }; + page.network_requests.clear(); + page + } + /// A collected page carrying one discoverable GPT slot, for `run_update_slots`. fn collected_page_with_header_slot() -> CollectedPage { let mut collected = collected_page(); @@ -2352,6 +2381,180 @@ mod tests { assert!(summary.contains("Draft config: review before validation and push")); } + #[test] + fn run_generate_extracts_lockr_app_id_from_page_metadata() { + for (metadata, expected_id) in [ + ( + r#""#, + "example-app-id", + ), + ( + r#""#, + "example-app-id", + ), + ( + r#""#, + "example-\"app\\id\nline", + ), + ] { + for with_script in [false, true] { + let temp = TempDir::new().expect("should create temp dir"); + let mut args = audit_args("https://publisher.example/page"); + args.js_assets = Some(temp.path().join("js-assets.toml")); + args.config = Some(temp.path().join("trusted-server.toml")); + let page = collected_lockr_page(metadata, with_script); + + run_generate(&args, &FakeCollector::new(page), &mut Vec::new()) + .expect("should generate outputs from offline lockr page"); + + let artifact_text = + fs::read_to_string(args.js_assets.as_ref().expect("should set audit path")) + .expect("should read audit artifact"); + let artifact: toml::Value = + toml::from_str(&artifact_text).expect("should parse audit artifact"); + assert_eq!( + artifact["lockr_app_id"].as_str(), + Some(expected_id), + "should persist the normalized public app ID" + ); + let integrations = artifact["detected_integrations"] + .as_array() + .expect("should record detected integrations"); + assert_eq!(integrations.len(), 1, "should detect lockr exactly once"); + assert_eq!( + integrations[0]["id"].as_str(), + Some("lockr"), + "should record lockr as the detected integration" + ); + assert_eq!( + integrations[0]["evidence"].as_str(), + Some("meta[name=\"lockr-signin-app_id\"]"), + "should retain metadata evidence even when a script also detects lockr" + ); + assert_eq!( + artifact["js_asset_count"].as_integer(), + Some(0), + "should not count inline scripts as JS assets" + ); + + let draft = + fs::read_to_string(args.config.as_ref().expect("should set config path")) + .expect("should read generated config"); + let config: toml::Value = + toml::from_str(&draft).expect("should parse generated config"); + let lockr = &config["integrations"]["lockr"]; + assert_eq!( + lockr["app_id"].as_str(), + Some(expected_id), + "should write the normalized app ID to the draft config" + ); + assert_eq!( + lockr["enabled"].as_bool(), + Some(false), + "discovering an ID should not enable lockr" + ); + assert!( + draft.contains("# - Detected lockr;"), + "should retain manual review" + ); + let warnings = validate::check_candidate(&draft, &draft) + .expect("should validate generated config"); + assert!( + warnings.is_empty(), + "should pass deploy validation: {warnings:?}" + ); + let enabled = draft.replacen( + "[integrations.lockr]\nenabled = false\n", + "[integrations.lockr]\nenabled = true\n", + 1, + ); + assert_ne!(enabled, draft, "should find the generated lockr section"); + validate::check_candidate(&enabled, &draft) + .expect("should pass deploy validation once lockr is enabled"); + assert!( + !draft.contains("# [integrations.lockr]"), + "should fill the template's lockr block instead of retaining a second example" + ); + assert_eq!( + draft + .lines() + .filter(|line| *line == "[integrations.lockr]") + .count(), + 1, + "should generate exactly one lockr table" + ); + let optional_setting = draft.replacen( + "# api_endpoint = \"https://identity.example.com\"", + "api_endpoint = \"https://identity.example.com\"", + 1, + ); + let with_optional: toml::Value = toml::from_str(&optional_setting) + .expect("should parse after uncommenting a lockr setting"); + assert_eq!( + with_optional["integrations"]["lockr"]["api_endpoint"].as_str(), + Some("https://identity.example.com"), + "should keep optional settings in the generated lockr section" + ); + validate::check_candidate(&optional_setting, &draft) + .expect("should validate after uncommenting a lockr setting"); + } + } + } + + #[test] + fn run_generate_keeps_lockr_without_app_id_on_manual_review_path() { + for (metadata, with_script) in [ + ("", true), + (r#""#, true), + (r#""#, true), + ( + r#""#, + true, + ), + ( + r#""#, + true, + ), + (r#""#, false), + (r#""#, false), + ( + r#""#, + false, + ), + ] { + let temp = TempDir::new().expect("should create temp dir"); + let mut args = audit_args("https://publisher.example/page"); + args.js_assets = Some(temp.path().join("js-assets.toml")); + args.config = Some(temp.path().join("trusted-server.toml")); + let page = collected_lockr_page(metadata, with_script); + + run_generate(&args, &FakeCollector::new(page), &mut Vec::new()) + .expect("should generate outputs without a lockr app ID"); + + let artifact_text = + fs::read_to_string(args.js_assets.as_ref().expect("should set audit path")) + .expect("should read audit artifact"); + let artifact: toml::Value = + toml::from_str(&artifact_text).expect("should parse audit artifact"); + assert!( + artifact.get("lockr_app_id").is_none(), + "should not invent an ID" + ); + let draft = fs::read_to_string(args.config.as_ref().expect("should set config path")) + .expect("should read generated config"); + let config: toml::Value = + toml::from_str(&draft).expect("should parse generated config"); + assert!( + config["integrations"].get("lockr").is_none(), + "should not activate a lockr section without an ID" + ); + assert!( + draft.contains("# - Detected lockr;"), + "should retain manual review" + ); + } + } + #[test] fn run_generate_respects_no_config() { let temp = TempDir::new().expect("should create temp dir"); @@ -2471,6 +2674,7 @@ mod tests { fn build_draft_config_writes_disabled_js_asset_proxy_candidates() { let url = Url::parse("https://publisher.example.com/page").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: Some("Example".to_string()), js_asset_count: 2, @@ -2553,6 +2757,7 @@ mod tests { fn generated_asset_proxy_paths_are_opaque() { let url = Url::parse("https://publisher.example.com/page").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 1, @@ -2593,6 +2798,7 @@ mod tests { fn asset_proxy_generation_deduplicates_and_summarizes_skips() { let url = Url::parse("https://publisher.example.com/page").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 4, @@ -2650,6 +2856,7 @@ mod tests { fn asset_proxy_generation_warns_about_query_string_candidates() { let url = Url::parse("https://publisher.example.com/page").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 2, @@ -2697,6 +2904,7 @@ mod tests { fn asset_proxy_generation_with_no_candidates_removes_placeholder_asset() { let url = Url::parse("https://publisher.example.com/page").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 1, @@ -2759,6 +2967,7 @@ mod tests { fn build_draft_config_uses_final_url_and_detected_integrations() { let url = Url::parse("https://www.publisher.example:8443/path").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: Some("Example".to_string()), js_asset_count: 2, @@ -2798,6 +3007,7 @@ mod tests { fn build_draft_config_requires_manual_review_for_didomi() { let url = Url::parse("https://publisher.example.com").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 0, @@ -2839,6 +3049,7 @@ mod tests { fn build_draft_config_extracts_gtm_container_id_from_url_evidence() { let url = Url::parse("https://publisher.example.com").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 0, @@ -2882,6 +3093,7 @@ mod tests { "https://tags.example.com/gtm.js?id=GTM-ABCDEFGHIJKLMNOPQRSTU", ] { let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 1, @@ -2926,6 +3138,7 @@ mod tests { fn build_draft_config_does_not_enable_gtm_without_container_id() { let url = Url::parse("https://publisher.example/path").expect("should parse URL"); let artifact = AuditArtifact { + lockr_app_id: None, audited_url: url.to_string(), page_title: None, js_asset_count: 1, diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 70227ca49..e8c37d1b7 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -342,6 +342,26 @@ register routes or rewrite HTML until you set runtime-injected scripts; JS Asset Proxy only rewrites matching script `src` URLs present in HTML processed by Trusted Server. +The audit also extracts the public lockr app ID from rendered page metadata: + +```html + +``` + +It trims the `content` value and records the first nonempty ID as `lockr_app_id` +in `js-assets.toml`, alongside the metadata source evidence. If the page contains +distinct nonempty IDs, the artifact includes a warning but retains the first ID. +The draft config fills the template's `[integrations.lockr].app_id` but explicitly +sets `enabled = false`. Missing or empty IDs are not populated; detected lockr +remains on the manual-review path. + +Finding an ID does not verify SDK/API behavior on another serving domain. If +Trusted Server serves a hostname that is not registered with lockr, set +`origin_override` to the publisher's authorized, registered origin. Enable lockr +on a staging service and complete the +[Lockr verification checks](/guide/integrations/lockr#verification) before enabling +it in production. The checks require the integration's routes to be enabled. + If a config already exists, avoid overwriting it: ```bash diff --git a/docs/guide/integrations/lockr.md b/docs/guide/integrations/lockr.md index 577f4e339..d16bff6ad 100644 --- a/docs/guide/integrations/lockr.md +++ b/docs/guide/integrations/lockr.md @@ -14,6 +14,15 @@ app_id = "example-lockr-app-id" The integration is off when the section is absent. When the section exists, `enabled` defaults to `true` and `app_id` must be nonempty. Use the application ID from your existing `lockr-signin-app_id` metadata. Keep the publisher's SDK loading, metadata, and initialization in place. The integration does not inject them. +To discover the ID automatically, run +[`ts audit generate `](/guide/cli#audit-a-public-page). It fills `app_id` from +nonempty page metadata but explicitly leaves the generated integration disabled. +If Trusted Server serves a hostname that is not registered with Lockr, set +`origin_override` to the publisher's authorized, registered origin. Enable the +integration on a staging service and run the [verification](#verification) checks +before enabling it in production. The checks require the integration's routes to +be enabled. + | Field | Default | Behavior | | ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------- | | `enabled` | `true` | Enable the integration when its section exists |