use serde::{Deserialize, Serialize}; /// A required secret with metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecretDefinition { /// Environment variable name (e.g., "PLUGIN_STEAM_API_KEY") pub key: String, /// Human-friendly name (e.g., "Steam Web API Key") pub name: String, /// Description of where to get the secret #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } /// Plugin manifest loaded from manifest.json #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginManifest { pub id: String, pub name: String, pub version: String, pub api_version: String, #[serde(skip_serializing_if = "Option::is_none")] pub icon_url: Option, /// A wide header image. Optional and independent of `icon_url`: a plugin /// may ship either, both, or neither, and every surface that shows one /// has to read well in all four cases. #[serde(skip_serializing_if = "Option::is_none")] pub banner_url: Option, #[serde(default)] pub required_secrets: Vec, /// Authentication type: "oauth2", "openid", "api_key" #[serde(default = "default_auth_type")] pub auth_type: String, /// JSON Schema describing user-provided configuration (e.g., API keys) #[serde(skip_serializing_if = "Option::is_none")] pub config_schema: Option, /// Description of the plugin #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// WASM file name (relative to manifest location) #[serde(default = "default_wasm_file")] pub wasm_file: String, /// Plugin kind; defaults to `auth` when omitted from the manifest. #[serde(default)] pub plugin_type: PluginType, /// The trust contract. The loader checks the module's imports against it. #[serde(default)] pub capabilities: Vec, /// Hosts `network:request` may reach. Bare hostnames; a leading `*.` allows subdomains. #[serde(default)] pub allowed_hosts: Vec, /// Publisher DID, once plugins are distributed through the registry. #[serde(default, skip_serializing_if = "Option::is_none")] pub publisher: Option, /// Library plugins this plugin calls through `host_call_library`. #[serde(default)] pub dependencies: Vec, /// Library only: the name scripts import. Defaults to `id`. #[serde(default, skip_serializing_if = "Option::is_none")] pub namespace: Option, /// Interpreter only: value stored in `scripts.script_type`. #[serde(default, skip_serializing_if = "Option::is_none")] pub language_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub file_extension: Option, /// Interpreter only: the extensions, without the dot, of the project /// files this interpreter runs. Empty means the one `file_extension` /// names, which is all a manifest written before project files existed /// declares; read it through [`LoadedPlugin::extensions`]. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extensions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub monaco_language: Option, /// Interpreter only: library ids it can bridge; `["*"]` means any, which /// is what a generic bridge declares and therefore the default. Another /// type's manifest carries the default too, since serde cannot see /// `plugin_type`; read it through /// [`LoadedPlugin::supports_libraries`](LoadedPlugin::supports_libraries), /// which is empty for everything but an interpreter. #[serde(default = "default_supports_libraries")] pub supports_libraries: Vec, } fn default_wasm_file() -> String { "plugin.wasm".to_string() } /// The extensions a manifest claims, whichever field names them. Shared by /// the accessor and the loader's shape check so the two read one list. pub(crate) fn effective_extensions(m: &PluginManifest) -> Vec<&str> { if m.extensions.is_empty() { m.file_extension.as_deref().into_iter().collect() } else { m.extensions.iter().map(String::as_str).collect() } } /// The wildcard an interpreter declares to bridge whatever is installed. pub const ANY_LIBRARY: &str = "*"; fn default_supports_libraries() -> Vec { vec![ANY_LIBRARY.to_string()] } /// What a plugin's `plugin_info()` export returns, and the SDK's definition of /// it — the host never redefines a type that crosses the WASM boundary. pub use happyview_plugin_sdk::wire::PluginInfo; impl From for PluginInfo { fn from(manifest: PluginManifest) -> Self { PluginInfo { id: manifest.id, name: manifest.name, version: manifest.version, api_version: manifest.api_version, icon_url: manifest.icon_url, // Extract just the keys from SecretDefinition for PluginInfo required_secrets: manifest .required_secrets .into_iter() .map(|s| s.key) .collect(), auth_type: manifest.auth_type, config_schema: manifest.config_schema, } } } fn default_auth_type() -> String { "oauth2".to_string() } /// What a plugin *is*. Decides which exports the host calls. A manifest /// that omits it is an auth plugin. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PluginType { #[default] Auth, Interpreter, Library, } impl PluginType { pub fn as_str(&self) -> &'static str { match self { Self::Auth => "auth", Self::Interpreter => "interpreter", Self::Library => "library", } } pub fn parse_str(s: &str) -> Option { match s { "auth" => Some(Self::Auth), "interpreter" => Some(Self::Interpreter), "library" => Some(Self::Library), _ => None, } } } /// A dependency on another library plugin. `version` is a semver requirement /// (`>=1.0.0`, `^1`, `*`); validated at load time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginDependency { pub id: String, #[serde(default = "default_version_req")] pub version: String, } fn default_version_req() -> String { "*".to_string() } /// The tokens `handle_callback()` and `refresh_tokens()` return, and the /// profile `get_profile()` returns. Read a token's expiry through /// [`TokenSetExt::resolved_expires_at`], since a plugin may give either an /// absolute instant or a duration. pub use happyview_plugin_sdk::wire::{ExternalProfile, StrongRef, TokenSet}; /// The host-side half of [`TokenSet`]: turning what a plugin reported into an /// absolute instant needs a clock, which a guest does not have. pub trait TokenSetExt { /// `expires_at` when the plugin gave one, otherwise `now + expires_in` when /// it gave a duration instead, otherwise `None`. An `expires_at` that is /// not RFC 3339 is an error. fn resolved_expires_at( &self, ) -> Result>, chrono::ParseError>; } impl TokenSetExt for TokenSet { fn resolved_expires_at( &self, ) -> Result>, chrono::ParseError> { if let Some(at) = &self.expires_at { return Ok(Some( chrono::DateTime::parse_from_rfc3339(at)?.with_timezone(&chrono::Utc), )); } Ok(self .expires_in .map(|secs| chrono::Utc::now() + chrono::Duration::seconds(secs as i64))) } } /// Plugin source - file or URL #[derive(Debug, Clone)] pub enum PluginSource { File { path: std::path::PathBuf }, Url { url: String, sha256: Option }, } /// Loaded plugin with runtime state pub struct LoadedPlugin { pub info: PluginInfo, pub source: PluginSource, pub wasm_bytes: Vec, /// Full manifest if loaded from manifest.json (contains secret metadata) pub manifest: Option, } impl LoadedPlugin { /// Derived from the manifest; `PluginInfo` is what the module's own /// `plugin_info()` reports and carries none of this. pub fn plugin_type(&self) -> PluginType { self.manifest .as_ref() .map(|m| m.plugin_type) .unwrap_or_default() } pub fn dependencies(&self) -> &[PluginDependency] { self.manifest .as_ref() .map(|m| m.dependencies.as_slice()) .unwrap_or(&[]) } /// What a surface shows about a plugin before it shows what it does. /// /// Only a manifest carries a description; the module's own report has no /// such field, so there is nothing to fall back to. pub fn description(&self) -> Option<&str> { self.manifest .as_ref() .and_then(|m| m.description.as_deref()) } /// The manifest's icon wins over the module's own report: a manifest is /// what the operator installed and can be read before the module runs. pub fn icon_url(&self) -> Option<&str> { self.manifest .as_ref() .and_then(|m| m.icon_url.as_deref()) .or(self.info.icon_url.as_deref()) } /// Independent of [`icon_url`](Self::icon_url): a plugin may ship either, /// both, or neither. Only a manifest carries one. pub fn banner_url(&self) -> Option<&str> { self.manifest.as_ref().and_then(|m| m.banner_url.as_deref()) } /// Import name for library plugins; `None` for every other type. pub fn namespace(&self) -> Option<&str> { let m = self.manifest.as_ref()?; if m.plugin_type != PluginType::Library { return None; } Some(m.namespace.as_deref().unwrap_or(&m.id)) } /// The libraries an interpreter may bridge; empty for every other type, /// which is what keeps a library's defaulted `["*"]` from being read as a /// declaration it never made. pub fn supports_libraries(&self) -> &[String] { match self.manifest.as_ref() { Some(m) if m.plugin_type == PluginType::Interpreter => &m.supports_libraries, _ => &[], } } /// The `scripts.script_type` an interpreter serves; `None` for every /// other type. The loader requires it of an interpreter, so a plugin the /// loader produced always has one. pub fn language_id(&self) -> Option<&str> { let m = self.manifest.as_ref()?; if m.plugin_type != PluginType::Interpreter { return None; } m.language_id.as_deref() } /// The project file extensions an interpreter runs; empty for every other /// type. Falls back to `file_extension`, so an interpreter released before /// the field existed still claims the extension it always named. pub fn extensions(&self) -> Vec<&str> { match self.manifest.as_ref() { Some(m) if m.plugin_type == PluginType::Interpreter => effective_extensions(m), _ => Vec::new(), } } /// What the manifest says. `manifest: None` declares nothing; the loader /// never produces such a `LoadedPlugin`, only tests do. pub fn declared_capabilities(&self) -> &[crate::plugin::capabilities::PluginCapability] { self.manifest .as_ref() .map(|m| m.capabilities.as_slice()) .unwrap_or(&[]) } pub fn allowed_hosts(&self) -> &[String] { self.manifest .as_ref() .map(|m| m.allowed_hosts.as_slice()) .unwrap_or(&[]) } /// `api_version` as a number; unparseable or absent reads as 1. The /// manifest is the source of truth when present (`info` is derived from it /// on the loader path, but tests construct `info` alone). pub fn api_version_number(&self) -> u32 { self.manifest .as_ref() .map(|m| m.api_version.as_str()) .unwrap_or(&self.info.api_version) .parse() .unwrap_or(1) } } #[cfg(test)] mod tests { use super::*; use crate::plugin::capabilities::PluginCapability; fn loaded(manifest: Option) -> LoadedPlugin { LoadedPlugin { info: PluginInfo { id: "x".into(), name: "X".into(), version: "1.0.0".into(), api_version: "1".into(), icon_url: None, required_secrets: vec![], auth_type: "oauth2".into(), config_schema: None, }, source: PluginSource::File { path: "/tmp/x".into(), }, wasm_bytes: vec![], manifest, } } #[test] fn manifest_without_plugin_type_is_auth() { let m: PluginManifest = serde_json::from_str( r#"{"id":"steam","name":"Steam","version":"1.0.0","api_version":"2"}"#, ) .unwrap(); assert_eq!(m.plugin_type, PluginType::Auth); assert!(m.dependencies.is_empty()); assert!(m.capabilities.is_empty()); assert_eq!(loaded(Some(m)).plugin_type(), PluginType::Auth); assert_eq!(loaded(None).plugin_type(), PluginType::Auth); } #[test] fn library_manifest_parses_v3_fields() { let m: PluginManifest = serde_json::from_str( r#"{"id":"record","name":"Record","version":"1.2.0","api_version":"2", "plugin_type":"library","namespace":"Record","publisher":"did:plc:abc", "capabilities":["database:read","library:call","network:request"], "allowed_hosts":["api.example.com","*.cdn.example.com"], "dependencies":[{"id":"db","version":">=1.0.0"},{"id":"http"}]}"#, ) .unwrap(); assert_eq!(m.plugin_type, PluginType::Library); assert_eq!(m.dependencies.len(), 2); assert_eq!(m.dependencies[0].version, ">=1.0.0"); assert_eq!(m.dependencies[1].version, "*"); assert_eq!(m.publisher.as_deref(), Some("did:plc:abc")); assert_eq!( m.capabilities, vec![ PluginCapability::DatabaseRead, PluginCapability::LibraryCall, PluginCapability::NetworkRequest ] ); let p = loaded(Some(m)); assert_eq!(p.namespace(), Some("Record")); assert_eq!(p.dependencies().len(), 2); assert_eq!(p.allowed_hosts(), &["api.example.com", "*.cdn.example.com"]); assert_eq!(p.api_version_number(), 2); } #[test] fn unknown_capability_is_a_parse_error() { let err = serde_json::from_str::( r#"{"id":"x","name":"X","version":"1.0.0","api_version":"2","plugin_type":"library", "capabilities":["teleport"]}"#, ) .unwrap_err(); assert!(err.to_string().contains("teleport"), "{err}"); } #[test] fn library_namespace_defaults_to_id() { let m: PluginManifest = serde_json::from_str( r#"{"id":"http","name":"HTTP","version":"1.0.0","api_version":"2","plugin_type":"library"}"#, ) .unwrap(); assert_eq!(loaded(Some(m)).namespace(), Some("http")); } #[test] fn auth_plugins_have_no_namespace_and_no_manifest_means_version_1() { let p = loaded(None); assert_eq!(p.namespace(), None); assert_eq!(p.api_version_number(), 1); assert!(p.declared_capabilities().is_empty()); } #[test] fn plugin_type_round_trips_through_str() { for t in [ PluginType::Auth, PluginType::Interpreter, PluginType::Library, ] { assert_eq!(PluginType::parse_str(t.as_str()), Some(t)); } assert_eq!(PluginType::parse_str("bogus"), None); } fn token_set(expires_at: Option<&str>, expires_in: Option) -> TokenSet { TokenSet { access_token: "a".into(), refresh_token: None, expires_at: expires_at.map(str::to_string), expires_in, token_type: "Bearer".into(), } } #[test] fn resolved_expires_at_prefers_an_explicit_timestamp_over_a_duration() { let ts = token_set(Some("2026-01-01T00:00:00Z"), Some(999)); let at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&chrono::Utc); assert_eq!(ts.resolved_expires_at(), Ok(Some(at))); } #[test] fn resolved_expires_at_reads_an_offset_timestamp_as_the_instant_it_names() { let ts = token_set(Some("2025-12-31T19:00:00-05:00"), None); let at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&chrono::Utc); assert_eq!(ts.resolved_expires_at(), Ok(Some(at))); } /// An unparseable `expires_at` is refused here, not at deserialisation: /// the wire type keeps it as a string so the SDK needs no clock or parser. #[test] fn resolved_expires_at_refuses_a_timestamp_it_cannot_parse() { assert!( token_set(Some("next tuesday"), Some(3600)) .resolved_expires_at() .is_err() ); } #[test] fn resolved_expires_at_derives_from_a_duration_when_no_timestamp_is_given() { let ts = token_set(None, Some(3600)); let resolved = ts .resolved_expires_at() .expect("a duration should parse") .expect("expires_in should derive an expiry"); let now = chrono::Utc::now(); assert!(resolved > now, "derived expiry must be in the future"); assert!( resolved <= now + chrono::Duration::seconds(3600), "derived expiry must not exceed now + expires_in" ); } #[test] fn resolved_expires_at_is_none_when_the_plugin_gave_neither() { let ts = token_set(None, None); assert_eq!(ts.resolved_expires_at(), Ok(None)); } #[test] fn token_set_deserializes_when_expires_in_is_absent() { let ts: TokenSet = serde_json::from_str(r#"{"access_token":"a","token_type":"Bearer"}"#).unwrap(); assert_eq!(ts.expires_in, None); } }