diff --git a/crates/pinakes-plugin-api/Cargo.toml b/crates/pinakes-plugin-api/Cargo.toml index 94f529a..bfc8dd4 100644 --- a/crates/pinakes-plugin-api/Cargo.toml +++ b/crates/pinakes-plugin-api/Cargo.toml @@ -10,6 +10,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } +tracing = { workspace = true } # For plugin manifest parsing toml = { workspace = true } diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index 49c5748..340dc24 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -40,6 +40,33 @@ pub struct UiSection { /// Widgets to inject into existing host pages #[serde(default)] pub widgets: Vec, + + /// API endpoint paths this plugin's UI requires. + /// Each must start with `/api/`. Informational; host may check availability. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required_endpoints: Vec, + + /// CSS custom property overrides provided by this plugin. + /// Keys are property names (e.g. `--accent-color`), values are CSS values. + /// The host applies these to `document.documentElement` on startup. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub theme_extensions: HashMap, +} + +impl UiSection { + /// Validate that all declared required endpoints start with `/api/`. + /// + /// # Errors + /// + /// Returns an error string for the first invalid endpoint found. + pub fn validate(&self) -> Result<(), String> { + for ep in &self.required_endpoints { + if !ep.starts_with("/api/") { + return Err(format!("required_endpoint must start with '/api/': {ep}")); + } + } + Ok(()) + } } /// Entry for a UI page in the manifest - can be inline or file reference @@ -267,6 +294,15 @@ impl PluginManifest { )); } + // Validate UI section (required_endpoints format); non-fatal: warn only + if let Err(e) = self.ui.validate() { + tracing::warn!( + plugin = %self.plugin.name, + error = %e, + "plugin UI section has invalid required_endpoints" + ); + } + // Validate UI pages for (idx, page_entry) in self.ui.pages.iter().enumerate() { match page_entry { @@ -663,4 +699,89 @@ gap = 16 let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.ui.pages.len(), 2); } + + #[test] + fn test_ui_section_validate_accepts_api_paths() { + let section = UiSection { + pages: vec![], + widgets: vec![], + required_endpoints: vec![ + "/api/v1/media".to_string(), + "/api/plugins/my-plugin/data".to_string(), + ], + theme_extensions: HashMap::new(), + }; + assert!(section.validate().is_ok()); + } + + #[test] + fn test_ui_section_validate_rejects_non_api_path() { + let section = UiSection { + pages: vec![], + widgets: vec![], + required_endpoints: vec!["/not-api/something".to_string()], + theme_extensions: HashMap::new(), + }; + assert!(section.validate().is_err()); + } + + #[test] + fn test_ui_section_validate_rejects_empty_sections_with_bad_path() { + let section = UiSection { + pages: vec![], + widgets: vec![], + required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()], + theme_extensions: HashMap::new(), + }; + let err = section.validate().unwrap_err(); + assert!( + err.contains("no-slash"), + "error should mention the bad endpoint" + ); + } + + #[test] + fn test_theme_extensions_roundtrip() { + let toml = r##" +[plugin] +name = "theme-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["theme_provider"] + +[plugin.binary] +wasm = "plugin.wasm" + +[ui.theme_extensions] +"--accent-color" = "#ff6b6b" +"--sidebar-width" = "280px" +"##; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!( + manifest.ui.theme_extensions.get("--accent-color").map(String::as_str), + Some("#ff6b6b") + ); + assert_eq!( + manifest.ui.theme_extensions.get("--sidebar-width").map(String::as_str), + Some("280px") + ); + } + + #[test] + fn test_theme_extensions_empty_by_default() { + let toml = r#" +[plugin] +name = "no-theme" +version = "1.0.0" +api_version = "1.0" +kind = ["media_type"] + +[plugin.binary] +wasm = "plugin.wasm" +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert!(manifest.ui.theme_extensions.is_empty()); + } }