diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 874ba927..17353660 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -5363,7 +5363,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -5686,7 +5686,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "pay" -version = "0.16.0" +version = "0.17.0" dependencies = [ "axum", "bs58", @@ -5730,7 +5730,7 @@ dependencies = [ [[package]] name = "pay-core" -version = "0.16.0" +version = "0.17.0" dependencies = [ "axum", "base64 0.22.1", @@ -5778,7 +5778,7 @@ dependencies = [ [[package]] name = "pay-integration" -version = "0.16.0" +version = "0.17.0" dependencies = [ "pay-core", "pay-types", @@ -5787,7 +5787,7 @@ dependencies = [ [[package]] name = "pay-keystore" -version = "0.16.0" +version = "0.17.0" dependencies = [ "bs58", "secret-service", @@ -5804,7 +5804,7 @@ dependencies = [ [[package]] name = "pay-mcp" -version = "0.16.0" +version = "0.17.0" dependencies = [ "base64 0.22.1", "crc32fast", @@ -5826,7 +5826,7 @@ dependencies = [ [[package]] name = "pay-pdb" -version = "0.16.0" +version = "0.17.0" dependencies = [ "async-stream", "axum", @@ -5848,7 +5848,7 @@ dependencies = [ [[package]] name = "pay-types" -version = "0.16.0" +version = "0.17.0" dependencies = [ "schemars 0.8.22", "serde", @@ -9220,7 +9220,7 @@ dependencies = [ [[package]] name = "solana-mpp" version = "0.6.0" -source = "git+https://github.com/solana-foundation/mpp-sdk?branch=main#b94bb9dd571b557d4fb27018c2b589a05d4d99a1" +source = "git+https://github.com/solana-foundation/mpp-sdk?branch=main#e0fd4b2208bab04824a2ee511b4f6b37f3e24e0c" dependencies = [ "base64 0.22.1", "bincode", @@ -11291,7 +11291,7 @@ dependencies = [ [[package]] name = "solana-x402" version = "0.1.0" -source = "git+https://github.com/solana-foundation/x402-sdk?branch=main#b19ea82c3ebc0240a75b19fa46a4fc8d8819c1ee" +source = "git+https://github.com/solana-foundation/x402-sdk?branch=main#006db2c6b7dbdd898edd60c3fd943ca9ee30fd85" dependencies = [ "base64 0.22.1", "bincode", @@ -13227,7 +13227,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ea8e1b01..ebe51f27 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "0.16.0" +version = "0.17.0" license = "MIT" [workspace.dependencies] diff --git a/rust/crates/cli/src/commands/catalog/check.rs b/rust/crates/cli/src/commands/catalog/check.rs index 8af3b445..6da9df7e 100644 --- a/rust/crates/cli/src/commands/catalog/check.rs +++ b/rust/crates/cli/src/commands/catalog/check.rs @@ -16,6 +16,7 @@ use std::path::{Path, PathBuf}; use clap::ValueEnum; use owo_colors::OwoColorize; +use pay_core::skills::build::{BuildOptions, BuildResult}; use pay_core::skills::probe::{ProbeConfig, ProbeReport}; use super::derive_fqn_from_path; @@ -150,28 +151,40 @@ impl CheckCommand { // Frontmatter + endpoint validation via the build core. Aborts before // probing if the YAML/category/length checks fail — no point probing // a file that's syntactically broken. - let options = pay_core::skills::build::BuildOptions { - probe: false, - probe_config: ProbeConfig::default(), - only: None, - previous_dist: None, - }; let validation_only = pay_core::skills::build::build_single_provider( - path, &fqn, &name, &operator, &origin, &options, + path, + &fqn, + &name, + &operator, + &origin, + &static_build_options(), ); - if !validation_only.errors.is_empty() { - print_validation_errors(&validation_only.errors); - std::process::exit(1); - } + let static_validation = self.handle_static_validation(validation_only); let provider = parse_single_provider(path)?; - let endpoint_count = provider.endpoints.len(); + let endpoint_count = static_validation + .endpoint_count + .max(provider.endpoints.len()); if self.no_probe { + self.render_static_warnings(&static_validation.warnings); + let level = if static_validation.warnings.is_empty() { + NoticeLevel::Success + } else { + NoticeLevel::Warning + }; + let title = if static_validation.warnings.is_empty() { + "PAY.md check successful" + } else { + "PAY.md check passed with warnings" + }; print_notice( - NoticeLevel::Success, - "PAY.md check successful", - &format!("{endpoint_count} endpoints walked, probe skipped (--no-probe)"), + level, + title, + &format!( + "{endpoint_count} endpoints walked, probe skipped (--no-probe){}", + warning_suffix(static_validation.warnings.len()) + ), ); return Ok(()); } @@ -179,8 +192,15 @@ impl CheckCommand { let report = run_probe(vec![provider], &self.probe_config()); let validation = validate_report(&report, self.strict); + self.render_static_warnings(&static_validation.warnings); self.render_verbose(&report, &validation); - self.emit_summary(&report, &validation, endpoint_count, "PAY.md") + self.emit_summary( + &report, + &validation, + endpoint_count, + &static_validation.warnings, + "PAY.md", + ) } // ── Mode 2a: explicit list of paths (CI) ──────────────────────────── @@ -214,6 +234,7 @@ impl CheckCommand { ); return Ok(()); } + let static_validation = self.static_check_paths(root, files)?; let providers = collect_specific_providers(root, files)?; if providers.is_empty() { print_notice( @@ -226,27 +247,44 @@ impl CheckCommand { let total_endpoints: usize = providers.iter().map(|p| p.endpoints.len()).sum(); if self.no_probe { - print_notice( - NoticeLevel::Success, - &format!("{title_prefix} check successful"), - &format!( - "{total_endpoints} endpoints walked across {} provider{}, probe skipped (--no-probe)", - providers.len(), - if providers.len() == 1 { "" } else { "s" }, - ), + self.render_static_warnings(&static_validation.warnings); + let level = if static_validation.warnings.is_empty() { + NoticeLevel::Success + } else { + NoticeLevel::Warning + }; + let title = if static_validation.warnings.is_empty() { + format!("{title_prefix} check successful") + } else { + format!("{title_prefix} check passed with warnings") + }; + let body = format!( + "{total_endpoints} endpoints walked across {} provider{}, probe skipped (--no-probe){}", + providers.len(), + if providers.len() == 1 { "" } else { "s" }, + warning_suffix(static_validation.warnings.len()), ); + print_notice(level, &title, &body); return Ok(()); } let report = run_probe(providers, &self.probe_config()); let validation = validate_report(&report, self.strict); + self.render_static_warnings(&static_validation.warnings); self.render_verbose(&report, &validation); - self.emit_summary(&report, &validation, total_endpoints, title_prefix) + self.emit_summary( + &report, + &validation, + total_endpoints, + &static_validation.warnings, + title_prefix, + ) } // ── Mode 3: full registry (read-only) ────────────────────────────── fn run_full_registry(self, root: &Path) -> pay_core::Result<()> { + let static_validation = self.static_check_registry(root); let providers = collect_all_providers(root)?; if providers.is_empty() { print_notice( @@ -259,22 +297,138 @@ impl CheckCommand { let total_endpoints: usize = providers.iter().map(|p| p.endpoints.len()).sum(); if self.no_probe { - print_notice( - NoticeLevel::Success, - "Registry check successful", - &format!( - "{total_endpoints} endpoints walked across {} provider{}, probe skipped (--no-probe)", - providers.len(), - if providers.len() == 1 { "" } else { "s" }, - ), + self.render_static_warnings(&static_validation.warnings); + let level = if static_validation.warnings.is_empty() { + NoticeLevel::Success + } else { + NoticeLevel::Warning + }; + let title = if static_validation.warnings.is_empty() { + "Registry check successful" + } else { + "Registry check passed with warnings" + }; + let body = format!( + "{total_endpoints} endpoints walked across {} provider{}, probe skipped (--no-probe){}", + providers.len(), + if providers.len() == 1 { "" } else { "s" }, + warning_suffix(static_validation.warnings.len()), ); + print_notice(level, title, &body); return Ok(()); } let report = run_probe(providers, &self.probe_config()); let validation = validate_report(&report, self.strict); + self.render_static_warnings(&static_validation.warnings); self.render_verbose(&report, &validation); - self.emit_summary(&report, &validation, total_endpoints, "Registry") + self.emit_summary( + &report, + &validation, + total_endpoints, + &static_validation.warnings, + "Registry", + ) + } + + // ── Static validation ─────────────────────────────────────────────── + + fn static_check_registry(&self, root: &Path) -> StaticValidation { + let result = pay_core::skills::build::build_with_options( + root, + "", + String::new(), + &static_build_options(), + ); + self.handle_static_validation(result) + } + + fn static_check_paths( + &self, + root: &Path, + files: &[PathBuf], + ) -> pay_core::Result { + let mut combined = StaticValidation::default(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + for file in files { + let full_path = if file.is_absolute() { + file.clone() + } else { + root.join(file) + }; + if !full_path.exists() { + warnings.push(format!("{}: file not found, skipped", file.display())); + continue; + } + let canonical = full_path.canonicalize().map_err(|e| { + pay_core::Error::Config(format!("invalid file `{}`: {e}", full_path.display())) + })?; + let (fqn, name, operator, origin) = derive_provider_identity(root, &canonical)?; + let result = pay_core::skills::build::build_single_provider( + &canonical, + &fqn, + &name, + &operator, + &origin, + &static_build_options(), + ); + combined.provider_count += result.index.provider_count; + combined.endpoint_count += result + .index + .providers + .iter() + .map(|provider| provider.endpoint_count) + .sum::(); + errors.extend(result.errors); + warnings.extend(result.warnings); + } + + if !errors.is_empty() { + self.render_static_warnings(&warnings); + self.render_static_errors(&errors); + std::process::exit(1); + } + combined.warnings = warnings; + Ok(combined) + } + + fn handle_static_validation(&self, result: BuildResult) -> StaticValidation { + if !result.errors.is_empty() { + self.render_static_warnings(&result.warnings); + self.render_static_errors(&result.errors); + std::process::exit(1); + } + StaticValidation { + provider_count: result.index.provider_count, + endpoint_count: result + .index + .providers + .iter() + .map(|provider| provider.endpoint_count) + .sum(), + warnings: result.warnings, + } + } + + fn render_static_errors(&self, errors: &[String]) { + if matches!(self.format, ReportFormat::Github) { + render_github_static_findings("error", errors); + } else { + print_validation_errors(errors); + } + } + + fn render_static_warnings(&self, warnings: &[String]) { + if warnings.is_empty() { + return; + } + match self.format { + ReportFormat::Github => render_github_static_findings("warning", warnings), + ReportFormat::Json => {} + ReportFormat::Table => print_validation_warnings(warnings), + } } // ── Shared rendering ──────────────────────────────────────────────── @@ -299,13 +453,14 @@ impl CheckCommand { report: &ProbeReport, validation: &ValidationReport, endpoint_count: usize, + static_warnings: &[String], title_prefix: &str, ) -> pay_core::Result<()> { // Optional markdown sidecar — written before any potential exit // so the file is on disk even when validation blocks. PR CI cats // this into `$GITHUB_STEP_SUMMARY`. if let Some(path) = &self.summary_out { - let body = render_markdown_summary(validation); + let body = render_markdown_summary(validation, static_warnings); std::fs::write(path, body) .map_err(|e| pay_core::Error::Config(format!("write {}: {e}", path.display())))?; } @@ -329,7 +484,8 @@ impl CheckCommand { } let stats = verdict_stats(validation); - let body = stats.format(endpoint_count, report.failed); + let static_warning_count = static_warnings.len(); + let body = stats.format(endpoint_count, report.failed, static_warning_count); if validation.has_errors() { print_notice( @@ -339,7 +495,7 @@ impl CheckCommand { ); std::process::exit(1); } - let warn = !stats.is_clean(); + let warn = !stats.is_clean() || static_warning_count > 0; let level = if warn { NoticeLevel::Warning } else { @@ -355,6 +511,66 @@ impl CheckCommand { } } +#[derive(Debug, Default)] +struct StaticValidation { + provider_count: usize, + endpoint_count: usize, + warnings: Vec, +} + +fn static_build_options() -> BuildOptions { + BuildOptions { + probe: false, + probe_config: ProbeConfig::default(), + only: None, + previous_dist: None, + } +} + +fn warning_suffix(count: usize) -> String { + if count == 0 { + String::new() + } else { + format!( + "\n{count} static validation warning{}", + if count == 1 { "" } else { "s" } + ) + } +} + +fn derive_provider_identity( + root: &Path, + path: &Path, +) -> pay_core::Result<(String, String, String, String)> { + let providers_root = root.join("providers"); + let provider_dir = path.parent().unwrap_or(path); + let rel = match provider_dir.strip_prefix(&providers_root) { + Ok(rel) => rel, + Err(_) => return derive_fqn_from_path(path), + }; + let segments: Vec = rel + .components() + .filter_map(|component| component.as_os_str().to_str().map(String::from)) + .filter(|segment| !segment.is_empty() && segment != "." && segment != "..") + .collect(); + if segments.is_empty() { + return Err(pay_core::Error::Config(format!( + "{} has no provider path under {}/providers", + path.display(), + root.display() + ))); + } + let fqn = segments.join("/"); + let name = segments.last().unwrap().clone(); + let operator = segments.first().unwrap().clone(); + let origin = if segments.len() >= 3 { + segments[segments.len() - 2].clone() + } else { + operator.clone() + }; + Ok((fqn, name, operator, origin)) +} + #[derive(Debug, Clone, Copy)] struct VerdictStats { providers: usize, @@ -369,7 +585,12 @@ impl VerdictStats { self.blocked == 0 && self.non_solana == 0 } - fn format(&self, endpoint_count: usize, probe_failed: usize) -> String { + fn format( + &self, + endpoint_count: usize, + probe_failed: usize, + static_warning_count: usize, + ) -> String { let mut lines = Vec::new(); lines.push(format!( "{} endpoint{} tested across {} provider{}", @@ -401,6 +622,12 @@ impl VerdictStats { if probe_failed > 0 && self.classified == 0 { lines.push(format!("{probe_failed} probe failure(s) — see --verbose")); } + if static_warning_count > 0 { + lines.push(format!( + "{static_warning_count} static validation warning{}", + if static_warning_count == 1 { "" } else { "s" }, + )); + } lines.join("\n") } } @@ -409,7 +636,7 @@ impl VerdictStats { /// Mirrors what the previous workflow generated via Python — top-line /// status, per-provider table, and per-provider details for non-Solana /// endpoints. -fn render_markdown_summary(validation: &ValidationReport) -> String { +fn render_markdown_summary(validation: &ValidationReport, static_warnings: &[String]) -> String { use std::fmt::Write; let mut out = String::new(); let _ = writeln!(out, "### Solana-compatibility verdict"); @@ -476,9 +703,22 @@ fn render_markdown_summary(validation: &ValidationReport) -> String { let _ = writeln!(out); let _ = writeln!(out, ""); } + if !static_warnings.is_empty() { + let _ = writeln!(out); + let _ = writeln!(out, "### Static validation warnings"); + let _ = writeln!(out); + for warning in static_warnings { + let first_line = warning.lines().next().unwrap_or(warning); + let _ = writeln!(out, "- `{}`", escape_markdown_inline(first_line)); + } + } out } +fn escape_markdown_inline(value: &str) -> String { + value.replace('`', "\\`") +} + fn verdict_stats(validation: &ValidationReport) -> VerdictStats { let providers = validation.providers.len(); let blocked = validation.providers.iter().filter(|p| p.block).count(); @@ -518,6 +758,41 @@ pub(super) fn print_validation_errors(errors: &[String]) { print_notice(NoticeLevel::Error, &title, body.trim_end()); } +fn print_validation_warnings(warnings: &[String]) { + let title = if warnings.len() == 1 { + "Validation warning".to_string() + } else { + format!("{} validation warnings", warnings.len()) + }; + let mut body = String::new(); + for warning in warnings { + let mut lines = warning.trim_end().lines(); + if let Some(first) = lines.next() { + body.push_str(&format!("- {first}\n")); + } + for line in lines { + body.push_str(&format!(" {line}\n")); + } + } + print_notice(NoticeLevel::Warning, &title, body.trim_end()); +} + +fn render_github_static_findings(kind: &str, findings: &[String]) { + for finding in findings { + println!( + "::{kind} title={title}::{message}", + title = encode_actions("pay-skills static validation"), + message = encode_actions(finding), + ); + } +} + +fn encode_actions(msg: &str) -> String { + msg.replace('%', "%25") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + #[cfg(test)] mod tests { use super::*; @@ -537,7 +812,7 @@ mod tests { let s = stats(1, 0, 3, 0); assert!(s.is_clean()); assert_eq!( - s.format(9, 0), + s.format(9, 0, 0), "9 endpoints tested across 1 provider\n3/3 gates compatible with Solana" ); } @@ -547,7 +822,7 @@ mod tests { let s = stats(1, 0, 1, 2); assert!(!s.is_clean()); assert_eq!( - s.format(9, 0), + s.format(9, 0, 0), "9 endpoints tested across 1 provider\n\ 1/3 gates compatible with Solana\n\ 2 non-Solana endpoints flagged" @@ -558,11 +833,22 @@ mod tests { fn verdict_stats_blocks_when_zero_solana_ok() { let s = stats(1, 1, 0, 2); assert_eq!( - s.format(9, 0), + s.format(9, 0, 0), "9 endpoints tested across 1 provider\n\ 0/2 gates compatible with Solana\n\ 2 non-Solana endpoints flagged\n\ 1 provider blocked (zero Solana-compatible gates)" ); } + + #[test] + fn verdict_stats_includes_static_warnings() { + let s = stats(1, 0, 1, 0); + assert_eq!( + s.format(1, 0, 2), + "1 endpoint tested across 1 provider\n\ + 1/1 gates compatible with Solana\n\ + 2 static validation warnings" + ); + } } diff --git a/rust/crates/cli/src/tui.rs b/rust/crates/cli/src/tui.rs index 77e43f7e..0671314a 100644 --- a/rust/crates/cli/src/tui.rs +++ b/rust/crates/cli/src/tui.rs @@ -1660,12 +1660,12 @@ fn render_money_flow(frame: &mut ratatui::Frame, area: Rect, color: Color, tick: } fn unavailable_qr() -> RenderedQr { - let lines = vec![Line::from(Span::styled( - "QR unavailable", - Style::default().fg(Color::DarkGray), - ))]; + let lines = ["Make this window larger", "to show the QR code"] + .into_iter() + .map(|text| Line::from(Span::styled(text, Style::default().fg(Color::DarkGray))).centered()) + .collect::>(); RenderedQr { - width: lines.first().map(Line::width).unwrap_or(0) as u16, + width: lines.iter().map(Line::width).max().unwrap_or(0) as u16, height: lines.len() as u16, lines, } @@ -2934,6 +2934,25 @@ mod tests { assert!(qr.is_none()); } + #[test] + fn unavailable_qr_asks_user_to_resize_window() { + let qr = unavailable_qr(); + let text = qr + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + assert_eq!(text, vec!["Make this window larger", "to show the QR code"]); + assert_eq!(qr.width, "Make this window larger".len() as u16); + assert_eq!(qr.height, 2); + } + #[test] fn build_onramp_redirect_url_targets_done_page() { assert_eq!( diff --git a/rust/crates/core/src/skills/build.rs b/rust/crates/core/src/skills/build.rs index ff691fd1..292ecd9c 100644 --- a/rust/crates/core/src/skills/build.rs +++ b/rust/crates/core/src/skills/build.rs @@ -149,6 +149,7 @@ pub struct BuildResult { /// Map of `"providers//.json"` → serialized JSON. pub detail_files: HashMap, pub errors: Vec, + pub warnings: Vec, } /// Options controlling a build run. @@ -295,6 +296,7 @@ fn collect_providers( root: &Path, options: &BuildOptions, errors: &mut Vec, + warnings: &mut Vec, ) -> Vec<(ProviderIndexEntry, String)> { let mut results = Vec::new(); let dir = root.join("providers"); @@ -335,6 +337,7 @@ fn collect_providers( options, previous.as_ref(), errors, + warnings, &mut results, ); @@ -352,6 +355,7 @@ fn walk_for_pay_md( options: &BuildOptions, previous: Option<&PreviousDist>, errors: &mut Vec, + warnings: &mut Vec, results: &mut Vec<(ProviderIndexEntry, String)>, ) { let pay_md = dir.join("PAY.md"); @@ -375,7 +379,8 @@ fn walk_for_pay_md( operator.clone() }; dispatch_provider( - &pay_md, &fqn, &name, &operator, &origin, root, options, previous, errors, results, + &pay_md, &fqn, &name, &operator, &origin, root, options, previous, errors, warnings, + results, ); return; } @@ -388,6 +393,7 @@ fn walk_for_pay_md( options, previous, errors, + warnings, results, ); } @@ -408,6 +414,7 @@ fn dispatch_provider( options: &BuildOptions, previous: Option<&PreviousDist>, errors: &mut Vec, + warnings: &mut Vec, results: &mut Vec<(ProviderIndexEntry, String)>, ) { if let Some(only) = &options.only @@ -436,7 +443,7 @@ fn dispatch_provider( eprintln!(" provider: {fqn}"); process_provider_md( - path, fqn, name, operator, origin, root, options, errors, results, + path, fqn, name, operator, origin, root, options, errors, warnings, results, ); } @@ -463,6 +470,7 @@ fn process_provider_md( root: &Path, options: &BuildOptions, errors: &mut Vec, + warnings: &mut Vec, results: &mut Vec<(ProviderIndexEntry, String)>, ) { let text = match fs::read_to_string(path) { @@ -518,6 +526,41 @@ fn process_provider_md( }; let openapi_doc = resolved.document; + if let (Some(source), Some(doc)) = (&spec.openapi, &openapi_doc) { + for finding in crate::skills::openapi::validate_committed_openapi_document( + source, + doc, + path.parent(), + &spec.meta.service_url, + spec.meta.sandbox_service_url.as_deref(), + ) { + match finding.severity { + crate::skills::openapi::CatalogFindingSeverity::Error => { + errors.push(format!("{fqn}: {}", finding.message)); + } + crate::skills::openapi::CatalogFindingSeverity::Warning => { + warnings.push(format!("{fqn}: {}", finding.message)); + } + } + } + } + + // Validate operation summaries (the text shown on the OS biometric prompt + // at payment time). Runs only on openapi-driven providers — inline + // `endpoints:` descriptions are already validated by `validate_provider`. + if let Some(doc) = &openapi_doc { + for finding in crate::skills::openapi::validate_operation_summary_findings(doc) { + match finding.severity { + crate::skills::openapi::CatalogFindingSeverity::Error => { + errors.push(format!("{fqn}: {}", finding.message)); + } + crate::skills::openapi::CatalogFindingSeverity::Warning => { + warnings.push(format!("{fqn}: {}", finding.message)); + } + } + } + } + // Probe each endpoint (when probing is on) and synthesize the rich // `DetailEndpoint` shape: probe-derived pricing wins over any inline // pricing in the spec, with the spec value as fallback for offline builds. @@ -597,6 +640,7 @@ pub fn build_single_provider( options: &BuildOptions, ) -> BuildResult { let mut errors = Vec::new(); + let mut warnings = Vec::new(); let mut results = Vec::new(); let root = path.parent().unwrap_or(Path::new(".")); @@ -609,6 +653,7 @@ pub fn build_single_provider( root, options, &mut errors, + &mut warnings, &mut results, ); @@ -636,6 +681,7 @@ pub fn build_single_provider( index, detail_files, errors, + warnings, } } @@ -793,9 +839,13 @@ fn collect_aggregators(root: &Path, errors: &mut Vec) -> Vec spec.pricing = Some(pricing), + None if probe.probe_status == "free" => spec.pricing = None, + None => {} } - DetailEndpoint { + Some(DetailEndpoint { spec, protocol: probe.paid.protocols, supported_usd: probe.paid.supported_usd, probe_status: Some(probe.probe_status), probe_description: probe.paid.description, - } + }) }) .collect() } +fn should_publish_probed_endpoint(probe: &crate::skills::probe::EndpointProbeResult) -> bool { + matches!(probe.probe_status.as_str(), "ok" | "free") +} + // ── Public API ───────────────────────────────────────────────────────────── /// Build the skills index from a registry directory using default options @@ -872,9 +933,10 @@ pub fn build_with_options( options: &BuildOptions, ) -> BuildResult { let mut errors = Vec::new(); + let mut warnings = Vec::new(); eprintln!("Collecting providers..."); - let providers = collect_providers(root, options, &mut errors); + let providers = collect_providers(root, options, &mut errors, &mut warnings); eprintln!("Collecting affiliates..."); let affiliates = collect_affiliates(root, &mut errors); @@ -925,5 +987,161 @@ pub fn build_with_options( index, detail_files, errors, + warnings, + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::thread; + + use serde_json::json; + + use super::*; + + fn endpoint( + method: &str, + path: &str, + pricing: Option, + ) -> crate::skills::openapi::ResolvedEndpoint { + crate::skills::openapi::ResolvedEndpoint { + spec: EndpointSpec { + method: method.to_string(), + path: path.to_string(), + description: format!("Fetch test endpoint for {path}"), + resource: None, + pricing, + }, + body_example: None, + } + } + + fn start_probe_server(expected_requests: usize) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let handle = thread::spawn(move || { + for stream in listener.incoming().take(expected_requests).flatten() { + handle_probe_request(stream); + } + }); + (format!("http://{addr}"), handle) + } + + fn handle_probe_request(mut stream: TcpStream) { + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .expect("read request line"); + + let mut content_length = 0usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).expect("read header"); + let trimmed = line.trim_end(); + if trimmed.is_empty() { + break; + } + if let Some(value) = trimmed + .strip_prefix("Content-Length:") + .or_else(|| trimmed.strip_prefix("content-length:")) + { + content_length = value.trim().parse().unwrap_or(0); + } + } + if content_length > 0 { + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).expect("read request body"); + } + + let path = request_line.split_whitespace().nth(1).unwrap_or("/"); + let (status, body) = match path { + "/paid" => ( + "402 Payment Required", + x402_body("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), + ), + "/free" => ("200 OK", r#"{"items":[]}"#.to_string()), + "/wrong-chain" => ("402 Payment Required", x402_body("eip155:8453")), + "/auth" => ( + "401 Unauthorized", + r#"{"error":"api key required"}"#.to_string(), + ), + _ => ("404 Not Found", r#"{"error":"not found"}"#.to_string()), + }; + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + } + + fn x402_body(network: &str) -> String { + json!({ + "x402Version": 2, + "error": "Payment Required", + "accepts": [{ + "scheme": "exact", + "network": network, + "asset": pay_types::stablecoin_mints::USDC_MAINNET, + "amount": "10000", + "payTo": "J7ZvJEspvwP1oRxQZ7mYmNmT22NTm3GWq3t7HEbvPZYx", + "maxTimeoutSeconds": 300 + }] + }) + .to_string() + } + + #[test] + fn build_keeps_solana_paid_and_free_200_endpoints() { + let (service_url, handle) = start_probe_server(4); + let endpoints = vec![ + endpoint("GET", "/paid", None), + endpoint( + "GET", + "/free", + Some(json!({ + "dimensions": [{ + "direction": "usage", + "unit": "requests", + "scale": 1, + "tiers": [{ "price_usd": 9.99 }] + }] + })), + ), + endpoint("GET", "/wrong-chain", None), + endpoint("GET", "/auth", None), + ]; + let options = BuildOptions { + probe: true, + probe_config: crate::skills::probe::ProbeConfig { + timeout_secs: 2, + concurrency: 1, + ..Default::default() + }, + only: None, + previous_dist: None, + }; + + let detail = build_detail_endpoints("test/provider", &service_url, endpoints, &options); + handle.join().expect("server thread"); + + let paths: Vec<&str> = detail.iter().map(|ep| ep.spec.path.as_str()).collect(); + assert_eq!(paths, vec!["/paid", "/free"]); + + let paid = &detail[0]; + assert_eq!(paid.probe_status.as_deref(), Some("ok")); + assert_eq!(paid.protocol, vec!["x402".to_string()]); + assert_eq!(paid.supported_usd, vec!["USDC".to_string()]); + assert!(paid.spec.pricing.is_some()); + + let free = &detail[1]; + assert_eq!(free.probe_status.as_deref(), Some("free")); + assert!(free.protocol.is_empty()); + assert!(free.supported_usd.is_empty()); + assert!(free.spec.pricing.is_none()); } } diff --git a/rust/crates/core/src/skills/openapi.rs b/rust/crates/core/src/skills/openapi.rs index eb4b1fbc..f9c993b5 100644 --- a/rust/crates/core/src/skills/openapi.rs +++ b/rust/crates/core/src/skills/openapi.rs @@ -28,6 +28,7 @@ use crate::{Error, Result}; const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete"]; const FETCH_TIMEOUT_SECS: u64 = 15; const MAX_SCHEMA_DEPTH: u32 = 6; +const MAX_COMMITTED_OPENAPI_BYTES: u64 = 1_048_576; /// One endpoint resolved from an OpenAPI document — both the spec entry that /// gets published to the index and the optional probe body extracted from @@ -146,6 +147,518 @@ fn parse_openapi3_endpoints(doc: &Value) -> Result> { Ok(endpoints) } +/// Length window for the reason text shown on the OS biometric prompt. +/// +/// The prompt truncates at 64 chars (`AuthIntent::authorize_payment_details` +/// in pay-keystore). Below 24 chars the line is too generic to authorize a +/// debit on — it's all an attacker needs to slip past a hurried user. +pub const SUMMARY_MIN_LEN: usize = 24; +pub const SUMMARY_MAX_LEN: usize = 63; + +/// Generic placeholders that must not appear as an operation's effective +/// reason text. Compared case-insensitively. `" "` is checked +/// separately because it depends on the operation. +const GENERIC_PLACEHOLDERS: &[&str] = &[ + "api access", + "endpoint", + "request", + "call", + "todo", + "fixme", + "tbd", +]; + +const TEMPLATE_TOKENS: &[&str] = &["{{", "", "fixme", "tbd", "xxx"]; +const ACTION_VERBS: &[&str] = &[ + "run", + "search", + "get", + "fetch", + "list", + "create", + "submit", + "send", + "generate", + "solve", + "translate", + "validate", + "verify", + "lookup", + "look", + "resolve", + "score", + "detect", + "classify", + "extract", + "parse", + "analyze", + "check", + "find", + "read", + "poll", + "start", + "cancel", + "upload", + "download", + "convert", + "render", + "capture", + "transcribe", + "synthesize", + "moderate", + "enrich", + "compare", + "calculate", + "estimate", + "simulate", + "buy", + "renew", + "deploy", + "host", + "report", +]; +const MARKETING_WORDS: &[&str] = &["best", "fastest", "cheapest", "unlimited", "free"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CatalogFindingSeverity { + Error, + Warning, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogFinding { + pub severity: CatalogFindingSeverity, + pub message: String, +} + +impl CatalogFinding { + fn error(message: String) -> Self { + Self { + severity: CatalogFindingSeverity::Error, + message, + } + } + + fn warning(message: String) -> Self { + Self { + severity: CatalogFindingSeverity::Warning, + message, + } + } +} + +/// Walk every operation in `doc` and validate the `summary` / `description` +/// text that becomes the OS payment prompt reason. +/// +/// Blocking errors enforce S1-S7 from the pay-skills contributing guide. +/// Warnings enforce S8-S10: distinct summaries, action-verb starts, and no +/// marketing superlatives. +pub fn validate_operation_summaries(doc: &Value) -> Vec { + validate_operation_summary_findings(doc) + .into_iter() + .filter(|finding| finding.severity == CatalogFindingSeverity::Error) + .map(|finding| finding.message) + .collect() +} + +pub fn validate_operation_summary_findings(doc: &Value) -> Vec { + let mut findings = Vec::new(); + let mut valid_summaries = Vec::new(); + collect_operation_summary_findings(doc, &mut findings, &mut valid_summaries); + add_duplicate_summary_warnings(&mut findings, &valid_summaries); + findings +} + +pub fn validate_committed_openapi_document( + source: &OpenapiSource, + doc: &Value, + spec_dir: Option<&std::path::Path>, + service_url: &str, + sandbox_service_url: Option<&str>, +) -> Vec { + let mut findings = Vec::new(); + match source { + OpenapiSource::Url { url } => findings.push(CatalogFinding::error(format!( + "openapi.url is not allowed in the public registry\n \ + got: `{url}`\n \ + commit the spec next to PAY.md and use `openapi: {{ path: openapi.json }}`" + ))), + OpenapiSource::Path { path } => { + let Some(dir) = spec_dir else { + findings.push(CatalogFinding::error(format!( + "openapi.path ({path}) requires a PAY.md directory anchor" + ))); + return findings; + }; + let resolved = dir.join(path); + match std::fs::metadata(&resolved) { + Ok(meta) if meta.len() > MAX_COMMITTED_OPENAPI_BYTES => { + findings.push(CatalogFinding::error(format!( + "openapi.path `{path}` is too large ({} bytes, max {MAX_COMMITTED_OPENAPI_BYTES})\n \ + trim the committed spec to the operations exposed through this gateway", + meta.len() + ))); + } + Ok(_) => {} + Err(e) => findings.push(CatalogFinding::error(format!( + "openapi.path metadata failed for {}: {e}", + resolved.display() + ))), + } + } + OpenapiSource::Content { content } => { + let len = content.len() as u64; + if len > MAX_COMMITTED_OPENAPI_BYTES { + findings.push(CatalogFinding::error(format!( + "openapi.content is too large ({len} bytes, max {MAX_COMMITTED_OPENAPI_BYTES})\n \ + use a committed `openapi.path` sidecar and trim it to the offered operations" + ))); + } + } + } + + if !is_openapi3_document(doc) { + findings.push(CatalogFinding::error( + "OpenAPI document must be valid OpenAPI 3.0 or 3.1\n \ + expected an `openapi` field starting with `3.0.` or `3.1.`" + .into(), + )); + } + + for reference in remote_refs(doc) { + findings.push(CatalogFinding::error(format!( + "OpenAPI document contains remote `$ref` `{reference}`\n \ + use same-document refs or committed relative sidecars only" + ))); + } + + findings.extend(validate_server_urls(doc, service_url, sandbox_service_url)); + findings +} + +#[derive(Debug, Clone)] +struct OperationSummary { + label: String, + effective: String, +} + +fn collect_operation_summary_findings( + doc: &Value, + findings: &mut Vec, + valid_summaries: &mut Vec, +) { + if let Some(paths) = doc.get("paths").and_then(|v| v.as_object()) { + for (path, item) in paths { + let Some(item_obj) = item.as_object() else { + continue; + }; + for &method in HTTP_METHODS { + let Some(op) = item_obj.get(method) else { + continue; + }; + validate_one_operation(op, method, path, findings, valid_summaries); + } + } + } + // Discovery docs (Google APIs): walk resources.*.methods.* recursively + // and any top-level `methods`. The effective field is `description` — + // Discovery has no `summary`. + if let Some(resources) = doc.get("resources").and_then(|v| v.as_object()) { + validate_discovery_resources(resources, findings, valid_summaries); + } + if let Some(methods) = doc.get("methods").and_then(|v| v.as_object()) { + validate_discovery_methods(methods, findings, valid_summaries); + } +} + +fn validate_discovery_resources( + resources: &Map, + findings: &mut Vec, + valid_summaries: &mut Vec, +) { + for resource in resources.values() { + if let Some(methods) = resource.get("methods").and_then(|v| v.as_object()) { + validate_discovery_methods(methods, findings, valid_summaries); + } + if let Some(nested) = resource.get("resources").and_then(|v| v.as_object()) { + validate_discovery_resources(nested, findings, valid_summaries); + } + } +} + +fn validate_discovery_methods( + methods: &Map, + findings: &mut Vec, + valid_summaries: &mut Vec, +) { + for (name, method) in methods { + let http_method = method + .get("httpMethod") + .and_then(|v| v.as_str()) + .unwrap_or("GET"); + let path = method + .get("path") + .or_else(|| method.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(name); + validate_one_operation(method, http_method, path, findings, valid_summaries); + } +} + +fn validate_one_operation( + op: &Value, + method: &str, + path: &str, + findings: &mut Vec, + valid_summaries: &mut Vec, +) { + let summary = op + .get("summary") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + let description = op + .get("description") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + let label = format!("{} {}", method.to_uppercase(), path); + + // S1: at least one non-empty field. + let raw = match summary.or(description) { + Some(s) => s, + None => { + findings.push(CatalogFinding::error(format!( + "{label}: operation has no `summary` or `description`\n \ + add a concrete `summary` (24–63 chars) — this becomes the `reason:` line \ + on the user's biometric payment prompt\n" + ))); + return; + } + }; + + // Collapse internal whitespace to count what the user will actually see. + let effective: String = raw.split_whitespace().collect::>().join(" "); + let len = effective.chars().count(); + + // S2 / S3: length window. + if len < SUMMARY_MIN_LEN { + findings.push(CatalogFinding::error(format!( + "{label}: operation summary too short ({len} chars, min {SUMMARY_MIN_LEN})\n \ + got: \"{effective}\"\n \ + write a concrete sentence, verb-first, naming the domain object — \ + this becomes the `reason:` line on the user's biometric payment prompt\n" + ))); + return; + } + if len > SUMMARY_MAX_LEN { + findings.push(CatalogFinding::error(format!( + "{label}: operation summary too long ({len} chars, max {SUMMARY_MAX_LEN})\n \ + got: \"{effective}\"\n \ + trim it — the OS biometric prompt truncates at 64 chars and shows `…`\n" + ))); + return; + } + + // S4: generic placeholders and METHOD-path echo. + let lowered = effective.to_ascii_lowercase(); + if GENERIC_PLACEHOLDERS + .iter() + .any(|p| lowered == *p || lowered == format!("the {p}")) + { + findings.push(CatalogFinding::error(format!( + "{label}: operation summary is a generic placeholder \"{effective}\"\n \ + write a specific verb-first sentence (e.g. `Run a BigQuery SQL query`)\n" + ))); + return; + } + let method_path_echo = format!("{} {}", method.to_uppercase(), path).to_ascii_lowercase(); + if lowered == method_path_echo { + findings.push(CatalogFinding::error(format!( + "{label}: operation summary echoes the method/path \"{effective}\"\n \ + write a sentence about what the call does, not its URL shape\n" + ))); + return; + } + + // S5: template tokens. + if let Some(token) = TEMPLATE_TOKENS.iter().find(|t| lowered.contains(*t)) { + findings.push(CatalogFinding::error(format!( + "{label}: operation summary contains unresolved template token `{token}`\n \ + got: \"{effective}\"\n \ + replace the placeholder with the real text\n" + ))); + return; + } + + // S6: markdown link syntax. + if effective.contains("](") { + findings.push(CatalogFinding::error(format!( + "{label}: operation summary contains markdown link syntax `](`\n \ + got: \"{effective}\"\n \ + the OS payment prompt renders plain text — drop the link\n" + ))); + return; + } + + // S7: ASCII-printable only. + if let Some(c) = effective.chars().find(|c| !matches!(c, '\x20'..='\x7E')) { + findings.push(CatalogFinding::error(format!( + "{label}: operation summary contains non-ASCII character `{c}` (U+{:04X})\n \ + got: \"{effective}\"\n \ + use plain ASCII — smart quotes, emoji, and zero-width chars render unpredictably\n", + c as u32 + ))); + return; + } + + if let Some(first) = first_token(&effective) + && !ACTION_VERBS.contains(&first.to_ascii_lowercase().as_str()) + { + findings.push(CatalogFinding::warning(format!( + "{label}: operation summary should start with an action verb\n \ + got: \"{effective}\"\n \ + start with a concrete verb such as `Search`, `Create`, `Fetch`, or `Generate`\n" + ))); + } + + if let Some(word) = marketing_word(&effective) { + findings.push(CatalogFinding::warning(format!( + "{label}: operation summary contains marketing language `{word}`\n \ + got: \"{effective}\"\n \ + describe the paid action plainly without superlatives or cost claims\n" + ))); + } + + valid_summaries.push(OperationSummary { label, effective }); +} + +fn add_duplicate_summary_warnings( + findings: &mut Vec, + summaries: &[OperationSummary], +) { + for (idx, summary) in summaries.iter().enumerate() { + if summaries[..idx] + .iter() + .any(|other| other.effective.eq_ignore_ascii_case(&summary.effective)) + { + continue; + } + let duplicates: Vec<&OperationSummary> = summaries + .iter() + .filter(|other| other.effective.eq_ignore_ascii_case(&summary.effective)) + .collect(); + if duplicates.len() < 2 { + continue; + } + let labels = duplicates + .iter() + .map(|op| op.label.as_str()) + .collect::>() + .join(", "); + findings.push(CatalogFinding::warning(format!( + "{}: operation summary is reused across multiple operations\n \ + got: \"{}\"\n \ + duplicate operations: {labels}\n", + summary.label, summary.effective + ))); + } +} + +fn first_token(value: &str) -> Option<&str> { + value + .split(|c: char| !c.is_ascii_alphabetic()) + .find(|part| !part.is_empty()) +} + +fn marketing_word(value: &str) -> Option<&'static str> { + for token in value.split(|c: char| !c.is_ascii_alphanumeric()) { + for word in MARKETING_WORDS { + if token.eq_ignore_ascii_case(word) { + return Some(word); + } + } + } + None +} + +fn is_openapi3_document(doc: &Value) -> bool { + doc.get("openapi") + .and_then(Value::as_str) + .is_some_and(|version| version.starts_with("3.0.") || version.starts_with("3.1.")) +} + +fn remote_refs(doc: &Value) -> Vec { + let mut refs = Vec::new(); + collect_remote_refs(doc, &mut refs); + refs.sort(); + refs.dedup(); + refs +} + +fn collect_remote_refs(value: &Value, refs: &mut Vec) { + match value { + Value::Object(map) => { + for (key, value) in map { + if key == "$ref" + && let Some(reference) = value.as_str() + && (reference.starts_with("http://") + || reference.starts_with("https://") + || reference.starts_with("//")) + { + refs.push(reference.to_string()); + } + collect_remote_refs(value, refs); + } + } + Value::Array(values) => { + for value in values { + collect_remote_refs(value, refs); + } + } + _ => {} + } +} + +fn validate_server_urls( + doc: &Value, + service_url: &str, + sandbox_service_url: Option<&str>, +) -> Vec { + let Some(servers) = doc.get("servers").and_then(Value::as_array) else { + return Vec::new(); + }; + let allowed = [Some(service_url), sandbox_service_url]; + let mut findings = Vec::new(); + for server in servers { + let Some(url) = server.get("url").and_then(Value::as_str) else { + continue; + }; + if url.starts_with('/') || url.contains('{') { + continue; + } + if !allowed + .iter() + .flatten() + .any(|allowed| same_origin_or_base(url, allowed)) + { + findings.push(CatalogFinding::warning(format!( + "OpenAPI servers[] entry does not match service_url or sandbox_service_url\n \ + got: `{url}`\n \ + expected base URL under `{service_url}`" + ))); + } + } + findings +} + +fn same_origin_or_base(server_url: &str, expected_base: &str) -> bool { + let server = server_url.trim_end_matches('/'); + let expected = expected_base.trim_end_matches('/'); + server == expected || server.starts_with(&format!("{expected}/")) +} + fn parse_discovery_endpoints(doc: &Value) -> Result> { let mut endpoints = Vec::new(); if let Some(resources) = doc.get("resources").and_then(|v| v.as_object()) { @@ -1850,6 +2363,114 @@ mod tests { assert_eq!(body, content); } + #[test] + fn operation_summary_findings_include_warnings() { + let doc = json!({ + "openapi": "3.1.0", + "paths": { + "/v1/a": { + "post": { "summary": "Best customer enrichment report" } + }, + "/v1/b": { + "post": { "summary": "Best customer enrichment report" } + } + } + }); + + let findings = validate_operation_summary_findings(&doc); + assert!( + findings + .iter() + .all(|finding| finding.severity == CatalogFindingSeverity::Warning), + "expected only warnings, got: {findings:?}" + ); + assert!( + findings + .iter() + .any(|finding| finding.message.contains("should start with an action verb")), + "expected action-verb warning, got: {findings:?}" + ); + assert!( + findings + .iter() + .any(|finding| finding.message.contains("marketing language `best`")), + "expected marketing warning, got: {findings:?}" + ); + assert!( + findings.iter().any(|finding| finding + .message + .contains("reused across multiple operations")), + "expected duplicate warning, got: {findings:?}" + ); + } + + #[test] + fn committed_openapi_validation_rejects_remote_refs_and_non_openapi3() { + let doc = json!({ + "swagger": "2.0", + "paths": {}, + "components": { + "schemas": { + "External": { "$ref": "https://example.com/schema.json" } + } + } + }); + let source = OpenapiSource::Content { + content: doc.to_string(), + }; + + let findings = validate_committed_openapi_document( + &source, + &doc, + None, + "https://api.example.com", + None, + ); + + assert!( + findings.iter().any(|finding| { + finding.severity == CatalogFindingSeverity::Error + && finding.message.contains("OpenAPI 3.0 or 3.1") + }), + "expected OpenAPI version error, got: {findings:?}" + ); + assert!( + findings.iter().any(|finding| { + finding.severity == CatalogFindingSeverity::Error + && finding.message.contains("remote `$ref`") + }), + "expected remote ref error, got: {findings:?}" + ); + } + + #[test] + fn committed_openapi_validation_warns_on_server_mismatch() { + let doc = json!({ + "openapi": "3.1.0", + "servers": [{ "url": "https://upstream.example.com" }], + "paths": {} + }); + let source = OpenapiSource::Content { + content: doc.to_string(), + }; + + let findings = validate_committed_openapi_document( + &source, + &doc, + None, + "https://api.example.com", + None, + ); + + assert!( + findings.iter().any(|finding| { + finding.severity == CatalogFindingSeverity::Warning + && finding.message.contains("servers[] entry does not match") + }), + "expected servers[] mismatch warning, got: {findings:?}" + ); + } + // ── Body example tests ── #[test] diff --git a/rust/crates/core/tests/surfpool_tests.rs b/rust/crates/core/tests/surfpool_tests.rs index 49d3232e..c66d629b 100644 --- a/rust/crates/core/tests/surfpool_tests.rs +++ b/rust/crates/core/tests/surfpool_tests.rs @@ -305,6 +305,174 @@ async fn full_payment_flow_with_surfnet() { assert!(resp.headers().get("payment-receipt").is_some()); } +// ============================================================================= +// Replay protection — the same authorization header cannot be used twice. +// +// This test answers: "is MPP replay a real issue in pay, or already covered +// upstream by solana-mpp?" (relevant to PR #359 which adds a duplicate replay +// cache in pay-core). +// +// Result: solana-mpp's built-in `signature_consumed` check (charge.rs ~545) is +// keyed on the on-chain transaction signature and rejects the second use. The +// pay-core middleware does not need its own replay store. +// ============================================================================= + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn replayed_authorization_is_rejected() { + use axum::Router; + use axum::middleware; + use axum::routing::any; + use pay_core::PaymentState; + use pay_types::metering::ApiSpec; + use solana_mpp::server::Mpp; + use solana_mpp::solana_keychain::memory::MemorySigner; + use std::sync::Arc; + + #[derive(Clone)] + struct S { + apis: Arc>, + mpp: Option, + } + impl PaymentState for S { + fn apis(&self) -> &[ApiSpec] { + &self.apis + } + fn mpp(&self) -> Option<&Mpp> { + self.mpp.as_ref() + } + } + + let surfnet = start_surfnet().await; + let recipient = Keypair::new(); + surfnet + .cheatcodes() + .fund_sol(&recipient.pubkey(), 1_000_000_000) + .unwrap(); + + let api: ApiSpec = + serde_yml::from_str(&std::fs::read_to_string("tests/fixtures/test-provider.yml").unwrap()) + .unwrap(); + + let mpp = Mpp::new(solana_mpp::server::Config { + recipient: recipient.pubkey().to_string(), + currency: "SOL".to_string(), + decimals: 9, + network: "localnet".to_string(), + rpc_url: Some(surfnet.rpc_url().to_string()), + secret_key: Some("test-secret".to_string()), + ..Default::default() + }) + .unwrap(); + + let state = S { + apis: Arc::new(vec![api]), + mpp: Some(mpp.clone()), + }; + + let app = Router::new() + .fallback(any(|| async { + axum::Json(serde_json::json!({"ok": true})) + })) + .layer(middleware::from_fn_with_state( + state.clone(), + pay_core::server::payment::payment_middleware::, + )) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let client = reqwest::Client::new(); + + // Step 1: Get a 402 challenge. + let resp = client + .post(format!("{url}/v1/simple/echo")) + .header("host", "testapi.localhost") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 402); + let www_auth = resp + .headers() + .get("www-authenticate") + .unwrap() + .to_str() + .unwrap() + .to_string(); + let challenge = solana_mpp::parse_www_authenticate(&www_auth).unwrap(); + + // Step 2: Build a payment credential. + let payer = Keypair::new(); + surfnet + .cheatcodes() + .fund_sol(&payer.pubkey(), 2_000_000_000) + .unwrap(); + let signer = MemorySigner::from_bytes(&payer.to_bytes()).unwrap(); + let rpc = + solana_mpp::solana_rpc_client::rpc_client::RpcClient::new(surfnet.rpc_url().to_string()); + let auth = solana_mpp::client::build_credential_header(&signer, &rpc, &challenge) + .await + .unwrap(); + + // Step 3: First call with the credential succeeds. + let resp = client + .post(format!("{url}/v1/simple/echo")) + .header("host", "testapi.localhost") + .header("authorization", &auth) + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "first call should succeed"); + assert!(resp.headers().get("payment-receipt").is_some()); + + // Step 4: Replay with the *same* authorization header. mpp-sdk's replay + // protection (charge.rs `signature_consumed` check) should reject it. + let resp = client + .post(format!("{url}/v1/simple/echo")) + .header("host", "testapi.localhost") + .header("authorization", &auth) + .body("{}") + .send() + .await + .unwrap(); + let status = resp.status(); + let body = resp.text().await.unwrap(); + assert_eq!( + status, 402, + "replayed credential must not be accepted (got {status}): {body}" + ); + assert!( + body.to_lowercase().contains("consumed") + || body.to_lowercase().contains("already") + || body.to_lowercase().contains("verification"), + "expected replay rejection in body, got: {body}" + ); + + // Step 5: Replay against a *different* metered path with the same + // credential. The challenge HMAC pinned the original resource, so this + // should also be rejected (credential mismatch or signature consumed). + // Skipping `/v1/simple/other` because non-metered paths bypass the MPP + // middleware entirely; using `/v1/translate` which is metered. + let resp = client + .post(format!("{url}/v1/translate")) + .header("host", "testapi.localhost") + .header("authorization", &auth) + .body("{}") + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 402, + "replayed credential on a different metered route must not be accepted" + ); +} + // ============================================================================= // Session intent — push mode full lifecycle (challenge → open → voucher → close) // ============================================================================= diff --git a/rust/crates/types/src/registry.rs b/rust/crates/types/src/registry.rs index 2d914097..4520d4d7 100644 --- a/rust/crates/types/src/registry.rs +++ b/rust/crates/types/src/registry.rs @@ -29,6 +29,15 @@ pub const KNOWN_CATEGORIES: &[&str] = &[ ]; pub const AFFILIATE_TYPES: &[&str] = &["agent", "cli", "platform"]; +const TUNNEL_HOST_SUFFIXES: &[&str] = &[ + ".ngrok.io", + ".ngrok-free.app", + ".trycloudflare.com", + ".loca.lt", + ".localtunnel.me", + ".localhost.run", + ".serveo.net", +]; /// Common metadata shared across all service representations (frontmatter, /// index entries, runtime catalog, search results, detail views). @@ -238,6 +247,18 @@ pub fn validate_provider(spec: &ProviderFrontmatter, fqn: &str) -> Vec { let mut errs = Vec::new(); let m = &spec.meta; + validate_fqn_segments(fqn, &mut errs); + + // ── Title ── + if m.title.trim().is_empty() { + errs.push(format!("{fqn}: missing required field `title`\n")); + } else if contains_placeholder(&m.title) { + errs.push(format!( + "{fqn}: title contains an unresolved placeholder\n got: \"{}\"\n", + m.title + )); + } + // ── Category ── if !KNOWN_CATEGORIES.contains(&m.category.as_str()) { errs.push(format!( @@ -248,6 +269,12 @@ pub fn validate_provider(spec: &ProviderFrontmatter, fqn: &str) -> Vec { } // ── Description (min 64, max 255) ── + if contains_placeholder(&m.description) { + errs.push(format!( + "{fqn}: description contains an unresolved placeholder\n got: \"{}\"\n", + m.description + )); + } if m.description.len() < 64 { errs.push(format!( "{fqn}: description too short ({} chars, min 64)\n got: \"{}\"\n", @@ -271,6 +298,11 @@ pub fn validate_provider(spec: &ProviderFrontmatter, fqn: &str) -> Vec { add a use_case field (32-255 chars) describing when this API should be used\n" )); } + Some(uc) if contains_placeholder(uc) => { + errs.push(format!( + "{fqn}: use_case contains an unresolved placeholder\n got: \"{uc}\"\n" + )); + } Some(uc) if uc.len() < 32 => { errs.push(format!( "{fqn}: use_case too short ({} chars, min 32)\n got: \"{uc}\"\n", @@ -288,18 +320,9 @@ pub fn validate_provider(spec: &ProviderFrontmatter, fqn: &str) -> Vec { } // ── service_url (HTTPS only, domain names only) ── - if m.service_url.is_empty() { - errs.push(format!("{fqn}: missing required field `service_url`\n")); - } else if !m.service_url.starts_with("https://") { - errs.push(format!( - "{fqn}: service_url must start with https://\n got: `{}`\n", - m.service_url - )); - } else if url_has_ip_address(&m.service_url) { - errs.push(format!( - "{fqn}: service_url must use a domain name, not an IP address\n got: `{}`\n", - m.service_url - )); + validate_registry_url(fqn, "service_url", &m.service_url, true, &mut errs); + if let Some(url) = &m.sandbox_service_url { + validate_registry_url(fqn, "sandbox_service_url", url, false, &mut errs); } // ── openapi: vs endpoints: (mutually exclusive, exactly one required) ── @@ -312,7 +335,7 @@ pub fn validate_provider(spec: &ProviderFrontmatter, fqn: &str) -> Vec { )), (false, false) => errs.push(format!( "{fqn}: must set either `openapi` or `endpoints`\n \ - add an `openapi: {{ url|path|content: ... }}` mapping or at least one endpoint\n" + add an `openapi: {{ path|content: ... }}` mapping or at least one endpoint\n" )), _ => {} } @@ -343,6 +366,12 @@ pub fn validate_provider(spec: &ProviderFrontmatter, fqn: &str) -> Vec { ep.description )); } + if contains_placeholder(&ep.description) { + errs.push(format!( + "{fqn}: {label} — description contains an unresolved placeholder\n got: \"{}\"\n", + ep.description + )); + } if ep.description.len() > 255 { errs.push(format!( "{fqn}: {label} — description too long ({} chars, max 255)\n got: \"{}...\"\n", @@ -409,21 +438,28 @@ fn validate_openapi_source(src: &OpenapiSource, fqn: &str) -> Vec { let mut errs = Vec::new(); match src { OpenapiSource::Url { url } => { - if url.is_empty() { - errs.push(format!("{fqn}: openapi.url is empty\n")); - } else if !url.starts_with("https://") { - errs.push(format!( - "{fqn}: openapi.url must be a fully-qualified https:// URL\n got: `{url}`\n" - )); - } else if url_has_ip_address(url) { - errs.push(format!( - "{fqn}: openapi.url must use a domain name, not an IP address\n got: `{url}`\n" - )); - } + errs.push(format!( + "{fqn}: openapi.url is not allowed in the public registry\n \ + got: `{url}`\n \ + commit the spec next to PAY.md and use `openapi: {{ path: openapi.json }}` \ + (or `content:` for tiny specs)\n" + )); } OpenapiSource::Path { path } => { if path.trim().is_empty() { errs.push(format!("{fqn}: openapi.path is empty\n")); + } else { + let p = std::path::Path::new(path); + if p.is_absolute() { + errs.push(format!( + "{fqn}: openapi.path must be relative to PAY.md\n got: `{path}`\n" + )); + } + if path.split('/').any(|part| part == "..") { + errs.push(format!( + "{fqn}: openapi.path must not escape the provider directory\n got: `{path}`\n" + )); + } } // Path is resolved relative to the provider's PAY.md at build // time; the resolved doc gets inlined into the published dist @@ -438,22 +474,120 @@ fn validate_openapi_source(src: &OpenapiSource, fqn: &str) -> Vec { errs } +fn validate_fqn_segments(fqn: &str, errs: &mut Vec) { + if fqn.trim().is_empty() { + errs.push("provider fqn is empty\n".to_string()); + return; + } + for segment in fqn.split('/') { + if segment.is_empty() { + errs.push(format!("{fqn}: FQN contains an empty path segment\n")); + continue; + } + if !is_url_safe_segment(segment) { + errs.push(format!( + "{fqn}: FQN segment `{segment}` must be lowercase and URL-safe\n \ + use lowercase letters, digits, and single hyphens (for example `market-data`)\n" + )); + } + } +} + +fn is_url_safe_segment(segment: &str) -> bool { + let bytes = segment.as_bytes(); + !bytes.is_empty() + && bytes[0] != b'-' + && bytes[bytes.len() - 1] != b'-' + && bytes + .iter() + .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-')) + && !segment.contains("--") +} + +fn contains_placeholder(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + ["todo", "fixme", "tbd", "{{", "", "your-"] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn validate_registry_url( + fqn: &str, + field: &str, + url: &str, + required: bool, + errs: &mut Vec, +) { + if url.trim().is_empty() { + if required { + errs.push(format!("{fqn}: missing required field `{field}`\n")); + } else { + errs.push(format!("{fqn}: {field} is empty\n")); + } + return; + } + if !url.starts_with("https://") { + errs.push(format!( + "{fqn}: {field} must start with https://\n got: `{url}`\n" + )); + return; + } + + let Some(host) = url_host(url) else { + errs.push(format!( + "{fqn}: {field} must include a production hostname\n got: `{url}`\n" + )); + return; + }; + + let host_lower = host.to_ascii_lowercase(); + if url_has_ip_address(url) + || host_lower == "localhost" + || host_lower.ends_with(".localhost") + || host_lower.ends_with(".local") + { + errs.push(format!( + "{fqn}: {field} must use a production domain name, not localhost or an IP address\n got: `{url}`\n" + )); + return; + } + + if TUNNEL_HOST_SUFFIXES + .iter() + .any(|suffix| host_lower.ends_with(suffix)) + { + errs.push(format!( + "{fqn}: {field} must not use a tunnel or temporary preview domain\n got: `{url}`\n" + )); + } +} + /// Check if a URL uses an IP address instead of a domain name. fn url_has_ip_address(url: &str) -> bool { + let Some(host) = url_host(url) else { + return false; + }; + if host.starts_with('[') && host.ends_with(']') { + return true; + } + host.parse::().is_ok() +} + +fn url_host(url: &str) -> Option<&str> { let after_scheme = url .strip_prefix("https://") .or_else(|| url.strip_prefix("http://")) .unwrap_or(url); - let host_port = after_scheme.split('/').next().unwrap_or(""); - - // Bracketed IPv6: [::1] or [::1]:8080 - if host_port.starts_with('[') { - return true; + let host_port = after_scheme.split(['/', '?', '#']).next().unwrap_or(""); + if host_port.is_empty() { + return None; + } + if let Some(end) = host_port.strip_prefix('[').and_then(|rest| rest.find(']')) { + let end = end + 1; + return Some(&host_port[..=end]); } - - // IPv4 or bare IPv6: strip port suffix let host = host_port.split(':').next().unwrap_or(""); - host.parse::().is_ok() + (!host.is_empty()).then_some(host) } pub fn validate_affiliate(spec: &AffiliateFrontmatter, name: &str) -> Vec { @@ -605,21 +739,22 @@ endpoints: } #[test] - fn openapi_with_url_passes_without_inline_endpoints() { + fn openapi_url_rejected_even_without_inline_endpoints() { let mut spec = valid_spec(); spec.endpoints = vec![]; spec.openapi = Some(OpenapiSource::Url { url: "https://api.example.com/openapi.json".into(), }); let errs = validate_provider(&spec, "test/test-api"); - assert!(errs.is_empty(), "expected no errors, got: {errs:?}"); + assert!( + errs.iter() + .any(|e| e.contains("openapi.url is not allowed")), + "expected committed-spec rejection, got: {errs:?}" + ); } #[test] fn openapi_relative_url_rejected() { - // Registry providers must use a fully-qualified https:// URL. - // Relative URLs would be ambiguous when the registry is consumed - // remotely; reject them at validation time. let mut spec = valid_spec(); spec.endpoints = vec![]; spec.openapi = Some(OpenapiSource::Url { @@ -628,8 +763,8 @@ endpoints: let errs = validate_provider(&spec, "test/test-api"); assert!( errs.iter() - .any(|e| e.contains("openapi.url must be a fully-qualified https://")), - "expected fully-qualified rejection, got: {errs:?}" + .any(|e| e.contains("openapi.url is not allowed")), + "expected committed-spec rejection, got: {errs:?}" ); } @@ -685,7 +820,7 @@ endpoints: } #[test] - fn openapi_url_must_be_https() { + fn openapi_http_url_rejected_as_remote_source() { let mut spec = valid_spec(); spec.endpoints = vec![]; spec.openapi = Some(OpenapiSource::Url { @@ -694,8 +829,8 @@ endpoints: let errs = validate_provider(&spec, "test/test-api"); assert!( errs.iter() - .any(|e| e.contains("openapi.url must be a fully-qualified https://")), - "expected https requirement error, got: {errs:?}" + .any(|e| e.contains("openapi.url is not allowed")), + "expected committed-spec rejection, got: {errs:?}" ); } @@ -709,8 +844,8 @@ endpoints: let errs = validate_provider(&spec, "test/test-api"); assert!( errs.iter() - .any(|e| e.contains("openapi.url must be a fully-qualified https://")), - "expected fully-qualified rejection, got: {errs:?}" + .any(|e| e.contains("openapi.url is not allowed")), + "expected committed-spec rejection, got: {errs:?}" ); }