From e7edad86f3a27431c2591178ea1da1d0a3b35bdd Mon Sep 17 00:00:00 2001 From: Trezy Date: Sun, 22 Mar 2026 09:57:53 -0500 Subject: [PATCH] feat: add support for plugin manifests and configuration via the dashboard --- .../20260322000000_add_plugin_manifest.sql | 2 + .../20260322000000_add_plugin_manifest.sql | 2 + src/admin/mod.rs | 5 + src/admin/permissions.rs | 18 +- src/admin/plugins.rs | 327 ++++++++++- src/admin/types.rs | 45 +- src/external_auth/routes.rs | 83 ++- src/main.rs | 23 +- src/plugin/loader.rs | 200 +++---- src/plugin/mod.rs | 36 +- src/plugin/types.rs | 65 ++- tests/plugin_executor.rs | 1 + tests/plugin_integration.rs | 2 + .../app/dashboard/settings/plugins/page.tsx | 522 ++++++++++++++++++ web/src/app/dashboard/settings/users/page.tsx | 3 +- web/src/components/app-sidebar.tsx | 2 + web/src/lib/api.ts | 71 +++ web/src/types/plugins.ts | 23 + 18 files changed, 1266 insertions(+), 164 deletions(-) create mode 100644 migrations/postgres/20260322000000_add_plugin_manifest.sql create mode 100644 migrations/sqlite/20260322000000_add_plugin_manifest.sql create mode 100644 web/src/app/dashboard/settings/plugins/page.tsx create mode 100644 web/src/types/plugins.ts diff --git a/migrations/postgres/20260322000000_add_plugin_manifest.sql b/migrations/postgres/20260322000000_add_plugin_manifest.sql new file mode 100644 index 0000000..fcd9c6c --- /dev/null +++ b/migrations/postgres/20260322000000_add_plugin_manifest.sql @@ -0,0 +1,2 @@ +-- Add manifest column to store full plugin manifest JSON +ALTER TABLE plugins ADD COLUMN manifest TEXT; diff --git a/migrations/sqlite/20260322000000_add_plugin_manifest.sql b/migrations/sqlite/20260322000000_add_plugin_manifest.sql new file mode 100644 index 0000000..fcd9c6c --- /dev/null +++ b/migrations/sqlite/20260322000000_add_plugin_manifest.sql @@ -0,0 +1,2 @@ +-- Add manifest column to store full plugin manifest JSON +ALTER TABLE plugins ADD COLUMN manifest TEXT; diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 0e08302..0b54071 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -91,6 +91,11 @@ pub fn admin_routes(_state: AppState) -> Router { put(settings::upsert).delete(settings::delete), ) .route("/plugins", post(plugins::add).get(plugins::list)) + .route("/plugins/preview", post(plugins::preview)) .route("/plugins/{id}", delete(plugins::remove)) .route("/plugins/{id}/reload", post(plugins::reload)) + .route( + "/plugins/{id}/secrets", + get(plugins::get_secrets).put(plugins::update_secrets), + ) } diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs index 5b558d6..100b844 100644 --- a/src/admin/permissions.rs +++ b/src/admin/permissions.rs @@ -69,6 +69,13 @@ pub enum Permission { #[serde(rename = "settings:manage")] SettingsManage, + + #[serde(rename = "plugins:read")] + PluginsRead, + #[serde(rename = "plugins:create")] + PluginsCreate, + #[serde(rename = "plugins:delete")] + PluginsDelete, } impl Permission { @@ -102,10 +109,13 @@ impl Permission { Self::RateLimitsCreate => "rate-limits:create", Self::RateLimitsDelete => "rate-limits:delete", Self::SettingsManage => "settings:manage", + Self::PluginsRead => "plugins:read", + Self::PluginsCreate => "plugins:create", + Self::PluginsDelete => "plugins:delete", } } - /// All 27 permissions. + /// All 30 permissions. pub fn all() -> HashSet { HashSet::from([ Self::LexiconsCreate, @@ -135,6 +145,9 @@ impl Permission { Self::RateLimitsCreate, Self::RateLimitsDelete, Self::SettingsManage, + Self::PluginsRead, + Self::PluginsCreate, + Self::PluginsDelete, ]) } } @@ -183,6 +196,9 @@ impl Template { perms.insert(Permission::RateLimitsCreate); perms.insert(Permission::RateLimitsDelete); perms.insert(Permission::SettingsManage); + perms.insert(Permission::PluginsRead); + perms.insert(Permission::PluginsCreate); + perms.insert(Permission::PluginsDelete); perms } Self::FullAccess => Permission::all(), diff --git a/src/admin/plugins.rs b/src/admin/plugins.rs index 4f80582..215767a 100644 --- a/src/admin/plugins.rs +++ b/src/admin/plugins.rs @@ -1,23 +1,29 @@ use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use std::collections::HashMap; use crate::AppState; use crate::db::{adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; +use crate::plugin::encryption::{decrypt, encrypt}; use crate::plugin::loader; use super::auth::UserAuth; use super::permissions::Permission; -use super::types::{AddPluginBody, PluginSummary}; +use super::types::{ + AddPluginBody, PluginPreviewResponse, PluginSecretsResponse, PluginSummary, + PluginsListResponse, PreviewPluginBody, UpdatePluginSecretsBody, +}; /// GET /admin/plugins - list all loaded plugins pub(super) async fn list( State(state): State, auth: UserAuth, -) -> Result>, AppError> { - auth.require(Permission::SettingsManage).await?; +) -> Result, AppError> { + auth.require(Permission::PluginsRead).await?; let plugins = state.plugin_registry.list().await; @@ -33,6 +39,30 @@ pub(super) async fn list( } }; + // Use manifest for rich secret metadata if available, otherwise fallback to basic keys + let required_secrets = if let Some(manifest) = &p.manifest { + manifest + .required_secrets + .iter() + .map(|s| super::types::SecretDefinition { + key: s.key.clone(), + name: s.name.clone(), + description: s.description.clone(), + }) + .collect() + } else { + // Legacy plugins without manifest - create minimal SecretDefinition from keys + p.info + .required_secrets + .iter() + .map(|key| super::types::SecretDefinition { + key: key.clone(), + name: key.clone(), // Use key as name for legacy + description: None, + }) + .collect() + }; + PluginSummary { id: p.info.id.clone(), name: p.info.name.clone(), @@ -42,13 +72,50 @@ pub(super) async fn list( sha256, enabled: true, // Currently all loaded plugins are enabled auth_type: p.info.auth_type.clone(), - required_secrets: p.info.required_secrets.clone(), + required_secrets, loaded_at: None, // Would need to track this in registry } }) .collect(); - Ok(Json(summaries)) + Ok(Json(PluginsListResponse { + plugins: summaries, + encryption_configured: state.config.token_encryption_key.is_some(), + })) +} + +/// POST /admin/plugins/preview - preview a plugin from URL (fetches manifest only) +pub(super) async fn preview( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result, AppError> { + auth.require(Permission::PluginsCreate).await?; + + let preview = loader::fetch_manifest(&state.http, &body.url) + .await + .map_err(|e| AppError::BadRequest(format!("Failed to fetch manifest: {}", e)))?; + + Ok(Json(PluginPreviewResponse { + id: preview.manifest.id, + name: preview.manifest.name, + version: preview.manifest.version, + description: preview.manifest.description, + icon_url: preview.manifest.icon_url, + auth_type: preview.manifest.auth_type, + required_secrets: preview + .manifest + .required_secrets + .into_iter() + .map(|s| super::types::SecretDefinition { + key: s.key, + name: s.name, + description: s.description, + }) + .collect(), + manifest_url: preview.manifest_url, + wasm_url: preview.wasm_url, + })) } /// POST /admin/plugins - add a new plugin from URL @@ -57,13 +124,41 @@ pub(super) async fn add( auth: UserAuth, Json(body): Json, ) -> Result, AppError> { - auth.require(Permission::SettingsManage).await?; + auth.require(Permission::PluginsCreate).await?; + + // Load plugin via manifest (required) + let preview = loader::fetch_manifest(&state.http, &body.url) + .await + .map_err(|e| AppError::BadRequest(format!("Failed to fetch manifest: {}", e)))?; - // Load plugin from URL - let plugin = loader::load_from_url(&state.http, &body.url, body.sha256.as_deref()) + let plugin = loader::load_from_manifest(&state.http, &preview, body.sha256.as_deref()) .await .map_err(|e| AppError::BadRequest(format!("Failed to load plugin: {}", e)))?; + // Use manifest for rich secret metadata if available + let required_secrets = if let Some(manifest) = &plugin.manifest { + manifest + .required_secrets + .iter() + .map(|s| super::types::SecretDefinition { + key: s.key.clone(), + name: s.name.clone(), + description: s.description.clone(), + }) + .collect() + } else { + plugin + .info + .required_secrets + .iter() + .map(|key| super::types::SecretDefinition { + key: key.clone(), + name: key.clone(), + description: None, + }) + .collect() + }; + let summary = PluginSummary { id: plugin.info.id.clone(), name: plugin.info.name.clone(), @@ -73,7 +168,7 @@ pub(super) async fn add( sha256: body.sha256.clone(), enabled: true, auth_type: plugin.info.auth_type.clone(), - required_secrets: plugin.info.required_secrets.clone(), + required_secrets, loaded_at: Some(now_rfc3339()), }; @@ -104,7 +199,7 @@ pub(super) async fn remove( auth: UserAuth, Path(plugin_id): Path, ) -> Result { - auth.require(Permission::SettingsManage).await?; + auth.require(Permission::PluginsDelete).await?; // Remove from registry let removed = state.plugin_registry.remove(&plugin_id).await; @@ -146,7 +241,7 @@ pub(super) async fn reload( auth: UserAuth, Path(plugin_id): Path, ) -> Result, AppError> { - auth.require(Permission::SettingsManage).await?; + auth.require(Permission::PluginsCreate).await?; // Get current plugin to find its source let current = state @@ -167,11 +262,39 @@ pub(super) async fn reload( // Remove old plugin state.plugin_registry.remove(&plugin_id).await; - // Load fresh from URL - let plugin = loader::load_from_url(&state.http, &url, sha256.as_deref()) + // Reload via manifest + let preview = loader::fetch_manifest(&state.http, &url) + .await + .map_err(|e| AppError::BadRequest(format!("Failed to fetch manifest: {}", e)))?; + + let plugin = loader::load_from_manifest(&state.http, &preview, sha256.as_deref()) .await .map_err(|e| AppError::BadRequest(format!("Failed to reload plugin: {}", e)))?; + // Use manifest for rich secret metadata if available + let required_secrets = if let Some(manifest) = &plugin.manifest { + manifest + .required_secrets + .iter() + .map(|s| super::types::SecretDefinition { + key: s.key.clone(), + name: s.name.clone(), + description: s.description.clone(), + }) + .collect() + } else { + plugin + .info + .required_secrets + .iter() + .map(|key| super::types::SecretDefinition { + key: key.clone(), + name: key.clone(), + description: None, + }) + .collect() + }; + let summary = PluginSummary { id: plugin.info.id.clone(), name: plugin.info.name.clone(), @@ -181,7 +304,7 @@ pub(super) async fn reload( sha256, enabled: true, auth_type: plugin.info.auth_type.clone(), - required_secrets: plugin.info.required_secrets.clone(), + required_secrets, loaded_at: Some(now_rfc3339()), }; @@ -203,3 +326,179 @@ pub(super) async fn reload( Ok(Json(summary)) } + +/// GET /admin/plugins/{id}/secrets - get plugin secrets (values masked) +pub(super) async fn get_secrets( + State(state): State, + auth: UserAuth, + Path(plugin_id): Path, +) -> Result, AppError> { + auth.require(Permission::PluginsRead).await?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("Encryption key not configured".into()))?; + + // Verify plugin exists + state + .plugin_registry + .get(&plugin_id) + .await + .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; + + // Get secrets from plugin_configs table + let sql = adapt_sql( + "SELECT config FROM plugin_configs WHERE plugin_id = ?", + state.db_backend, + ); + + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(&plugin_id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("Failed to fetch config: {}", e)))?; + + let secrets: HashMap = match row { + Some((config_json,)) => { + let config: serde_json::Value = serde_json::from_str(&config_json) + .map_err(|e| AppError::Internal(format!("Invalid config JSON: {}", e)))?; + + // Extract and decrypt secrets, then mask for display + if let Some(secrets_obj) = config.get("secrets").and_then(|s| s.as_object()) { + secrets_obj + .iter() + .filter_map(|(k, v)| { + v.as_str().and_then(|encrypted_b64| { + // Decode base64 and decrypt + let encrypted = BASE64.decode(encrypted_b64).ok()?; + let decrypted = decrypt(encryption_key, &encrypted).ok()?; + let val = String::from_utf8(decrypted).ok()?; + + // Mask the value for display + let masked = if val.len() > 8 { + format!("********{}", &val[val.len() - 4..]) + } else { + "********".to_string() + }; + Some((k.clone(), masked)) + }) + }) + .collect() + } else { + HashMap::new() + } + } + None => HashMap::new(), + }; + + Ok(Json(PluginSecretsResponse { plugin_id, secrets })) +} + +/// PUT /admin/plugins/{id}/secrets - update plugin secrets +pub(super) async fn update_secrets( + State(state): State, + auth: UserAuth, + Path(plugin_id): Path, + Json(body): Json, +) -> Result { + auth.require(Permission::PluginsCreate).await?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("Encryption key not configured".into()))?; + + // Verify plugin exists + state + .plugin_registry + .get(&plugin_id) + .await + .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; + + // Get existing config or create new one + let sql = adapt_sql( + "SELECT config FROM plugin_configs WHERE plugin_id = ?", + state.db_backend, + ); + + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(&plugin_id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("Failed to fetch config: {}", e)))?; + + let mut config: serde_json::Value = match row { + Some((config_json,)) => serde_json::from_str(&config_json) + .map_err(|e| AppError::Internal(format!("Invalid config JSON: {}", e)))?, + None => serde_json::json!({}), + }; + + // Get existing encrypted secrets to preserve unchanged values + let existing_secrets: HashMap = config + .get("secrets") + .and_then(|s| s.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + // Merge secrets: if new value starts with "********", keep existing encrypted value + // Otherwise, encrypt the new value + let mut merged_secrets = serde_json::Map::new(); + for (key, value) in body.secrets { + if value.starts_with("********") { + // Keep existing encrypted value if present + if let Some(existing) = existing_secrets.get(&key) { + merged_secrets.insert(key, serde_json::Value::String(existing.clone())); + } + } else if !value.is_empty() { + // Encrypt new value and store as base64 + let encrypted = encrypt(encryption_key, value.as_bytes()) + .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; + let encoded = BASE64.encode(&encrypted); + merged_secrets.insert(key, serde_json::Value::String(encoded)); + } + // Empty values are not stored (allows clearing a secret) + } + + // Update config with merged secrets + config["secrets"] = serde_json::Value::Object(merged_secrets); + + let config_json = serde_json::to_string(&config) + .map_err(|e| AppError::Internal(format!("Failed to serialize config: {}", e)))?; + + // Upsert into plugin_configs + let sql = adapt_sql( + "INSERT INTO plugin_configs (plugin_id, config, updated_at) VALUES (?, ?, ?) + ON CONFLICT (plugin_id) DO UPDATE SET config = EXCLUDED.config, updated_at = EXCLUDED.updated_at", + state.db_backend, + ); + + sqlx::query(&sql) + .bind(&plugin_id) + .bind(&config_json) + .bind(now_rfc3339()) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("Failed to update secrets: {}", e)))?; + + log_event( + &state.db, + EventLog { + event_type: "plugin.secrets_updated".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(plugin_id), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/admin/types.rs b/src/admin/types.rs index 53e26ac..cda4826 100644 --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -231,6 +231,12 @@ pub(super) struct TransferSuperBody { // Plugin types // --------------------------------------------------------------------------- +#[derive(Serialize)] +pub(super) struct PluginsListResponse { + pub(super) plugins: Vec, + pub(super) encryption_configured: bool, +} + #[derive(Serialize)] pub(super) struct PluginSummary { pub(super) id: String, @@ -241,7 +247,7 @@ pub(super) struct PluginSummary { pub(super) sha256: Option, pub(super) enabled: bool, pub(super) auth_type: String, - pub(super) required_secrets: Vec, + pub(super) required_secrets: Vec, pub(super) loaded_at: Option, } @@ -251,6 +257,43 @@ pub(super) struct AddPluginBody { pub(super) sha256: Option, } +#[derive(Deserialize)] +pub(super) struct PreviewPluginBody { + pub(super) url: String, +} + +#[derive(Serialize)] +pub(super) struct SecretDefinition { + pub(super) key: String, + pub(super) name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) description: Option, +} + +#[derive(Serialize)] +pub(super) struct PluginPreviewResponse { + pub(super) id: String, + pub(super) name: String, + pub(super) version: String, + pub(super) description: Option, + pub(super) icon_url: Option, + pub(super) auth_type: String, + pub(super) required_secrets: Vec, + pub(super) manifest_url: String, + pub(super) wasm_url: String, +} + +#[derive(Serialize)] +pub(super) struct PluginSecretsResponse { + pub(super) plugin_id: String, + pub(super) secrets: std::collections::HashMap, +} + +#[derive(Deserialize)] +pub(super) struct UpdatePluginSecretsBody { + pub(super) secrets: std::collections::HashMap, +} + // --------------------------------------------------------------------------- // Rate limit types // --------------------------------------------------------------------------- diff --git a/src/external_auth/routes.rs b/src/external_auth/routes.rs index 0b779b5..9a24482 100644 --- a/src/external_auth/routes.rs +++ b/src/external_auth/routes.rs @@ -101,8 +101,14 @@ async fn authorize( // Get plugin config (empty for now, could come from DB) let config = serde_json::Value::Null; - // Load secrets from environment - let secrets = load_plugin_secrets(&plugin_id); + // Load secrets from DB (with env var fallback) + let secrets = load_plugin_secrets( + &app_state.db, + app_state.db_backend, + app_state.config.token_encryption_key.as_ref(), + &plugin_id, + ) + .await; // Create executor and instance let executor = PluginExecutor::new( @@ -200,7 +206,13 @@ async fn callback_inner( state_param: &str, ) -> Result<(), Box> { let config = serde_json::Value::Null; - let secrets = load_plugin_secrets(&stored_state.plugin_id); + let secrets = load_plugin_secrets( + &app_state.db, + app_state.db_backend, + app_state.config.token_encryption_key.as_ref(), + &stored_state.plugin_id, + ) + .await; let executor = PluginExecutor::new( app_state.wasm_runtime.clone(), @@ -276,7 +288,13 @@ async fn connect_with_config( )); } - let secrets = load_plugin_secrets(&plugin_id); + let secrets = load_plugin_secrets( + &app_state.db, + app_state.db_backend, + app_state.config.token_encryption_key.as_ref(), + &plugin_id, + ) + .await; let executor = PluginExecutor::new( app_state.wasm_runtime.clone(), @@ -342,7 +360,13 @@ async fn sync( let user_did = claims.did(); let config = serde_json::Value::Null; - let secrets = load_plugin_secrets(&plugin_id); + let secrets = load_plugin_secrets( + &app_state.db, + app_state.db_backend, + app_state.config.token_encryption_key.as_ref(), + &plugin_id, + ) + .await; let executor = PluginExecutor::new( app_state.wasm_runtime.clone(), @@ -417,7 +441,54 @@ async fn unlink( }))) } -fn load_plugin_secrets(plugin_id: &str) -> HashMap { +async fn load_plugin_secrets( + db: &sqlx::Pool, + db_backend: crate::db::DatabaseBackend, + encryption_key: Option<&[u8; 32]>, + plugin_id: &str, +) -> HashMap { + use crate::plugin::encryption::decrypt; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + + // Try to load from database first (if encryption key is available) + if let Some(key) = encryption_key { + let sql = crate::db::adapt_sql( + "SELECT config FROM plugin_configs WHERE plugin_id = ?", + db_backend, + ); + + if let Ok(Some((config_json,))) = sqlx::query_as::<_, (String,)>(&sql) + .bind(plugin_id) + .fetch_optional(db) + .await + && let Ok(config) = serde_json::from_str::(&config_json) + && let Some(secrets_obj) = config.get("secrets").and_then(|s| s.as_object()) + { + // DB keys are full env var names (e.g., PLUGIN_STEAM_API_KEY) + // Strip prefix to get short names for plugin (e.g., API_KEY) + let prefix = format!("PLUGIN_{}_", plugin_id.to_uppercase()); + let db_secrets: HashMap = secrets_obj + .iter() + .filter_map(|(k, v)| { + v.as_str().and_then(|encrypted_b64| { + // Decode base64 and decrypt + let encrypted = BASE64.decode(encrypted_b64).ok()?; + let decrypted = decrypt(key, &encrypted).ok()?; + let value = String::from_utf8(decrypted).ok()?; + // Strip prefix from key to get short name + let short_key = k.strip_prefix(&prefix).unwrap_or(k).to_string(); + Some((short_key, value)) + }) + }) + .collect(); + + if !db_secrets.is_empty() { + return db_secrets; + } + } + } + + // Fall back to environment variables let prefix = format!("PLUGIN_{}_", plugin_id.to_uppercase()); std::env::vars() .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|name| (name.to_string(), v))) diff --git a/src/main.rs b/src/main.rs index 26d59e5..1b8fba2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -202,13 +202,26 @@ async fn main() { // Load plugins from PLUGIN_URLS env var if let Ok(urls) = std::env::var("PLUGIN_URLS") { for (id, url, sha256) in happyview::plugin::loader::parse_plugin_urls(&urls) { - match happyview::plugin::loader::load_from_url(&http, &url, sha256.as_deref()).await { - Ok(plugin) => { - tracing::info!(id = %id, "Loaded plugin from URL"); - plugin_registry.register(plugin).await; + match happyview::plugin::loader::fetch_manifest(&http, &url).await { + Ok(preview) => { + match happyview::plugin::loader::load_from_manifest( + &http, + &preview, + sha256.as_deref(), + ) + .await + { + Ok(plugin) => { + tracing::info!(id = %id, "Loaded plugin from URL"); + plugin_registry.register(plugin).await; + } + Err(e) => { + tracing::error!(id = %id, error = %e, "Failed to load plugin WASM"); + } + } } Err(e) => { - tracing::error!(id = %id, error = %e, "Failed to load plugin"); + tracing::error!(id = %id, error = %e, "Failed to fetch plugin manifest"); } } } diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs index 7ca71b8..cfc92ce 100644 --- a/src/plugin/loader.rs +++ b/src/plugin/loader.rs @@ -1,11 +1,6 @@ -use crate::plugin::host::{PluginState, register_host_functions}; -use crate::plugin::memory::PluginResponse; -use crate::plugin::runtime::DEFAULT_FUEL; -use crate::plugin::{LoadedPlugin, PluginInfo, PluginSource}; +use crate::plugin::{LoadedPlugin, PluginInfo, PluginManifest, PluginSource}; use sha2::{Digest, Sha256}; -use std::collections::HashMap; use std::path::Path; -use wasmtime::{Config, Engine, Linker, Module, Store}; const SUPPORTED_API_VERSION: &str = "1"; @@ -25,39 +20,68 @@ pub enum LoadError { MissingSecret(String), #[error("WASM validation failed: {0}")] WasmValidation(String), + #[error("Manifest not found at {0}")] + ManifestNotFound(String), } -/// Load a plugin from a file path -pub async fn load_from_file(path: &Path) -> Result { - let wasm_path = path.join("plugin.wasm"); - let wasm_bytes = tokio::fs::read(&wasm_path).await?; +/// Preview result with manifest and derived WASM URL +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginPreview { + pub manifest: PluginManifest, + pub manifest_url: String, + pub wasm_url: String, +} - // Try to load plugin.toml for metadata override - let toml_path = path.join("plugin.toml"); - let _toml_content = tokio::fs::read_to_string(&toml_path).await.ok(); +/// Fetch plugin manifest from a URL (or derive manifest URL from WASM URL) +pub async fn fetch_manifest( + client: &reqwest::Client, + url: &str, +) -> Result { + // If URL ends with .wasm, derive manifest URL from same directory + let (manifest_url, base_url) = if url.ends_with(".wasm") { + let base = url.rsplit_once('/').map(|(b, _)| b).unwrap_or(""); + (format!("{}/manifest.json", base), base.to_string()) + } else if url.ends_with("manifest.json") { + let base = url.rsplit_once('/').map(|(b, _)| b).unwrap_or(""); + (url.to_string(), base.to_string()) + } else { + // Assume it's a base directory URL + ( + format!("{}/manifest.json", url.trim_end_matches('/')), + url.trim_end_matches('/').to_string(), + ) + }; - // Extract plugin info by instantiating WASM and calling plugin_info() - // For now, create placeholder - full implementation needs wasmtime integration - let info = extract_plugin_info(&wasm_bytes)?; + let response = client + .get(&manifest_url) + .send() + .await? + .error_for_status() + .map_err(|_| LoadError::ManifestNotFound(manifest_url.clone()))?; - validate_api_version(&info)?; + let manifest: PluginManifest = response.json().await?; - Ok(LoadedPlugin { - info, - source: PluginSource::File { - path: path.to_path_buf(), - }, - wasm_bytes, + // Derive WASM URL from manifest + let wasm_url = format!("{}/{}", base_url, manifest.wasm_file); + + Ok(PluginPreview { + manifest, + manifest_url, + wasm_url, }) } -/// Load a plugin from a URL -pub async fn load_from_url( +/// Load a plugin from a manifest (fetches WASM separately) +pub async fn load_from_manifest( client: &reqwest::Client, - url: &str, + preview: &PluginPreview, expected_sha256: Option<&str>, ) -> Result { - let response = client.get(url).send().await?.error_for_status()?; + let response = client + .get(&preview.wasm_url) + .send() + .await? + .error_for_status()?; let wasm_bytes = response.bytes().await?.to_vec(); // Verify SHA256 if provided @@ -74,121 +98,45 @@ pub async fn load_from_url( } } - let info = extract_plugin_info(&wasm_bytes)?; + let info: PluginInfo = preview.manifest.clone().into(); validate_api_version(&info)?; Ok(LoadedPlugin { info, source: PluginSource::Url { - url: url.to_string(), + url: preview.wasm_url.clone(), sha256: expected_sha256.map(String::from), }, wasm_bytes, + manifest: Some(preview.manifest.clone()), }) } -/// Extract plugin info by instantiating WASM and calling plugin_info() -fn extract_plugin_info(wasm_bytes: &[u8]) -> Result { - match tokio::runtime::Handle::try_current() { - Ok(handle) => { - tokio::task::block_in_place(|| handle.block_on(extract_plugin_info_async(wasm_bytes))) - } - Err(_) => { - let rt = tokio::runtime::Runtime::new().map_err(|e| { - LoadError::WasmValidation(format!("failed to create runtime: {}", e)) - })?; - rt.block_on(extract_plugin_info_async(wasm_bytes)) - } - } -} - -/// Async implementation of plugin info extraction via WASM instantiation -async fn extract_plugin_info_async(wasm_bytes: &[u8]) -> Result { - // Create async-enabled engine with fuel - let mut config = Config::new(); - config.async_support(true); - config.consume_fuel(true); - let engine = Engine::new(&config).map_err(|e| LoadError::WasmValidation(e.to_string()))?; - - let module = - Module::new(&engine, wasm_bytes).map_err(|e| LoadError::WasmValidation(e.to_string()))?; - - // Create linker with host functions - let mut linker = Linker::new(&engine); - register_host_functions(&mut linker).map_err(|e| LoadError::WasmValidation(e.to_string()))?; - - // Create minimal state - no db needed for plugin_info() - let state = PluginState { - plugin_id: "loading".into(), - scope: "".into(), - secrets: HashMap::new(), - config: serde_json::Value::Null, - db: None, // Not needed for plugin_info - db_backend: crate::db::DatabaseBackend::Sqlite, - http_client: reqwest::Client::new(), - lexicons: std::sync::Arc::new(crate::lexicon::LexiconRegistry::new()), - usage: Default::default(), - memory: None, - alloc: None, - dealloc: None, - }; - - let mut store = Store::new(&engine, state); - store - .set_fuel(DEFAULT_FUEL) - .map_err(|e| LoadError::WasmValidation(e.to_string()))?; - - // Instantiate - let instance = linker - .instantiate_async(&mut store, &module) - .await - .map_err(|e| LoadError::WasmValidation(format!("instantiation failed: {}", e)))?; - - // Get memory and alloc/dealloc - let memory = instance - .get_memory(&mut store, "memory") - .ok_or_else(|| LoadError::WasmValidation("missing memory export".into()))?; - let alloc = instance - .get_typed_func::(&mut store, "alloc") - .map_err(|_| LoadError::WasmValidation("missing alloc export".into()))?; - let dealloc = instance - .get_typed_func::<(u32, u32), ()>(&mut store, "dealloc") - .map_err(|_| LoadError::WasmValidation("missing dealloc export".into()))?; - - // Store in state - store.data_mut().memory = Some(memory); - store.data_mut().alloc = Some(alloc); - store.data_mut().dealloc = Some(dealloc); - - // Call plugin_info - let func = instance - .get_typed_func::<(), i64>(&mut store, "plugin_info") - .map_err(|_| LoadError::WasmValidation("missing plugin_info export".into()))?; - - let packed = func - .call_async(&mut store, ()) +/// Load a plugin from a local directory (requires manifest.json) +pub async fn load_from_file(path: &Path) -> Result { + // Load manifest.json + let manifest_path = path.join("manifest.json"); + let manifest_content = tokio::fs::read_to_string(&manifest_path) .await - .map_err(|e| LoadError::WasmValidation(format!("plugin_info failed: {}", e)))?; + .map_err(|_| LoadError::ManifestNotFound(manifest_path.display().to_string()))?; - // Unpack i64: upper 32 bits = ptr, lower 32 bits = len - let ptr = (packed >> 32) as u32; - let len = (packed & 0xFFFFFFFF) as u32; + let manifest: PluginManifest = serde_json::from_str(&manifest_content)?; - // Read result from memory - let mem_data = memory.data(&store); - if (ptr as usize) + (len as usize) > mem_data.len() { - return Err(LoadError::WasmValidation( - "plugin_info returned out of bounds pointer".into(), - )); - } - let bytes = mem_data[ptr as usize..(ptr as usize + len as usize)].to_vec(); + // Load WASM file specified in manifest + let wasm_path = path.join(&manifest.wasm_file); + let wasm_bytes = tokio::fs::read(&wasm_path).await?; - // Parse response - let response: PluginResponse = serde_json::from_slice(&bytes)?; + let info: PluginInfo = manifest.clone().into(); + validate_api_version(&info)?; - response - .into_result() - .map_err(|e| LoadError::WasmValidation(format!("plugin error: {}", e.message))) + Ok(LoadedPlugin { + info, + source: PluginSource::File { + path: path.to_path_buf(), + }, + wasm_bytes, + manifest: Some(manifest), + }) } fn validate_api_version(info: &PluginInfo) -> Result<(), LoadError> { diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 77a496b..c164b0d 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -72,16 +72,23 @@ impl PluginRegistry { PluginSource::Url { url, sha256 } => ("url", Some(url.clone()), sha256.clone()), }; + // Serialize manifest to JSON if present + let manifest_json = plugin + .manifest + .as_ref() + .and_then(|m| serde_json::to_string(m).ok()); + let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO plugins (id, source, url, sha256, enabled, loaded_at, api_version) - VALUES (?, ?, ?, ?, 1, ?, ?) + "INSERT INTO plugins (id, source, url, sha256, enabled, loaded_at, api_version, manifest) + VALUES (?, ?, ?, ?, 1, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET source = excluded.source, url = excluded.url, sha256 = excluded.sha256, loaded_at = excluded.loaded_at, - api_version = excluded.api_version", + api_version = excluded.api_version, + manifest = excluded.manifest", self.db_backend, ); @@ -92,6 +99,7 @@ impl PluginRegistry { .bind(sha256) .bind(&now) .bind(&plugin.info.api_version) + .bind(manifest_json) .execute(db) .await?; @@ -137,14 +145,24 @@ impl PluginRegistry { match source.as_str() { "url" => { if let Some(url) = url { - match loader::load_from_url(http, &url, sha256.as_deref()).await { - Ok(plugin) => { - tracing::info!(plugin_id = %id, "Loaded plugin from DB"); - self.plugins.write().await.insert(id, Arc::new(plugin)); - loaded += 1; + // Load via manifest + match loader::fetch_manifest(http, &url).await { + Ok(preview) => { + match loader::load_from_manifest(http, &preview, sha256.as_deref()) + .await + { + Ok(plugin) => { + tracing::info!(plugin_id = %id, "Loaded plugin from DB"); + self.plugins.write().await.insert(id, Arc::new(plugin)); + loaded += 1; + } + Err(e) => { + tracing::error!(plugin_id = %id, error = %e, "Failed to load plugin WASM"); + } + } } Err(e) => { - tracing::error!(plugin_id = %id, error = %e, "Failed to load plugin from DB"); + tracing::error!(plugin_id = %id, error = %e, "Failed to fetch plugin manifest"); } } } diff --git a/src/plugin/types.rs b/src/plugin/types.rs index 0062f67..6772c5b 100644 --- a/src/plugin/types.rs +++ b/src/plugin/types.rs @@ -1,6 +1,47 @@ use serde::{Deserialize, Serialize}; -/// Plugin metadata returned by plugin_info() +/// 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, + #[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, +} + +fn default_wasm_file() -> String { + "plugin.wasm".to_string() +} + +/// Plugin metadata returned by plugin_info() - kept for backward compatibility #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginInfo { pub id: String, @@ -19,6 +60,26 @@ pub struct PluginInfo { pub config_schema: Option, } +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() } @@ -106,4 +167,6 @@ 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, } diff --git a/tests/plugin_executor.rs b/tests/plugin_executor.rs index 570f078..258a1a2 100644 --- a/tests/plugin_executor.rs +++ b/tests/plugin_executor.rs @@ -58,6 +58,7 @@ fn load_test_plugin() -> LoadedPlugin { path: "tests/fixtures/test_plugin".into(), }, wasm_bytes, + manifest: None, } } diff --git a/tests/plugin_integration.rs b/tests/plugin_integration.rs index a8f0ea4..6309b50 100644 --- a/tests/plugin_integration.rs +++ b/tests/plugin_integration.rs @@ -25,6 +25,7 @@ async fn test_plugin_registry_crud() { path: "/tmp/test".into(), }, wasm_bytes: vec![], + manifest: None, }; // Register @@ -67,6 +68,7 @@ async fn test_plugin_registry_multiple() { path: "/tmp/test".into(), }, wasm_bytes: vec![], + manifest: None, }; registry.register(plugin).await; } diff --git a/web/src/app/dashboard/settings/plugins/page.tsx b/web/src/app/dashboard/settings/plugins/page.tsx new file mode 100644 index 0000000..895f04a --- /dev/null +++ b/web/src/app/dashboard/settings/plugins/page.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Plus, Trash2, RefreshCw, ExternalLink, Settings, Loader2, AlertTriangle } from "lucide-react"; + +import { useCurrentUser } from "@/hooks/use-current-user"; +import { getPlugins, addPlugin, removePlugin, reloadPlugin, getPluginSecrets, updatePluginSecrets, previewPlugin, type PluginPreview } from "@/lib/api"; +import type { PluginSummary } from "@/types/plugins"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/responsive-dialog"; + +function formatAuthType(authType: string): string { + const formats: Record = { + oauth2: "OAuth 2.0", + openid: "OpenID", + api_key: "API Key", + }; + return formats[authType] || authType; +} + +export default function PluginsPage() { + const { hasPermission } = useCurrentUser(); + const [plugins, setPlugins] = useState([]); + const [encryptionConfigured, setEncryptionConfigured] = useState(true); + const [error, setError] = useState(null); + const [reloading, setReloading] = useState(null); + const [removing, setRemoving] = useState(null); + + // Add plugin dialog state + const [addOpen, setAddOpen] = useState(false); + const [newUrl, setNewUrl] = useState(""); + const [adding, setAdding] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [pluginPreview, setPluginPreview] = useState(null); + + // Configure secrets dialog state + const [configOpen, setConfigOpen] = useState(false); + const [configPlugin, setConfigPlugin] = useState(null); + const [secretValues, setSecretValues] = useState>({}); + const [savingSecrets, setSavingSecrets] = useState(false); + + const canCreate = hasPermission("plugins:create"); + const canDelete = hasPermission("plugins:delete"); + + const load = useCallback(async () => { + try { + const response = await getPlugins(); + setPlugins(response.plugins); + setEncryptionConfigured(response.encryption_configured); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + async function handlePreview() { + if (!newUrl.trim()) return; + + setPreviewing(true); + setError(null); + try { + const preview = await previewPlugin(newUrl.trim()); + setPluginPreview(preview); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setPreviewing(false); + } + } + + async function handleAdd() { + if (!pluginPreview) return; + + setAdding(true); + setError(null); + try { + await addPlugin({ url: pluginPreview.wasm_url }); + setAddOpen(false); + setNewUrl(""); + setPluginPreview(null); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setAdding(false); + } + } + + function handleCancelAdd() { + setAddOpen(false); + setNewUrl(""); + setPluginPreview(null); + setError(null); + } + + async function handleReload(id: string) { + setReloading(id); + setError(null); + try { + await reloadPlugin(id); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setReloading(null); + } + } + + async function handleRemove(id: string) { + setRemoving(id); + setError(null); + try { + await removePlugin(id); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setRemoving(null); + } + } + + async function handleOpenConfig(plugin: PluginSummary) { + setConfigPlugin(plugin); + setError(null); + try { + const response = await getPluginSecrets(plugin.id); + // Initialize with existing secrets (masked) and empty strings for missing ones + const initial: Record = {}; + for (const secret of plugin.required_secrets) { + initial[secret.key] = response.secrets[secret.key] || ""; + } + setSecretValues(initial); + setConfigOpen(true); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + async function handleSaveSecrets() { + if (!configPlugin) return; + setSavingSecrets(true); + setError(null); + try { + await updatePluginSecrets(configPlugin.id, secretValues); + setConfigOpen(false); + setConfigPlugin(null); + setSecretValues({}); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSavingSecrets(false); + } + } + + return ( + <> + +
+ {error &&

{error}

} + + {!encryptionConfigured && ( +
+ +
+

Encryption not configured

+

+ Plugin secrets cannot be stored without an encryption key. Set the{" "} + TOKEN_ENCRYPTION_KEY{" "} + environment variable to a base64-encoded 32-byte key. +

+

+ Generate one with: openssl rand -base64 32 +

+
+
+ )} + +
+
+

External Auth Plugins

+

+ Manage WASM plugins that provide authentication with external platforms. +

+
+ {canCreate && ( + { + if (!open) handleCancelAdd(); + else setAddOpen(true); + }}> + + + + + + + {pluginPreview ? `Install ${pluginPreview.name}?` : "Add Plugin"} + + + {pluginPreview + ? "Review the plugin details below before installing." + : "Enter a plugin URL to preview its details."} + + + + {!pluginPreview ? ( + // Step 1: Enter URL +
+
+ + setNewUrl(e.target.value)} + disabled={previewing} + /> +

+ Link to the .wasm file or manifest.json (GitHub Releases URL) +

+
+
+ ) : ( + // Step 2: Show preview +
+
+ {pluginPreview.icon_url && ( + + )} +
+

{pluginPreview.name}

+

+ {pluginPreview.description || `Version ${pluginPreview.version}`} +

+
+ {pluginPreview.version} +
+ +
+
+ Auth Type + + {formatAuthType(pluginPreview.auth_type)} + +
+ {pluginPreview.required_secrets.length > 0 && ( +
+ Required Configuration +
+ {pluginPreview.required_secrets.map((secret) => ( +
+
+ {secret.name} + {secret.key} +
+ {secret.description && ( +

{secret.description}

+ )} +
+ ))} +
+
+ )} +
+
+ )} + + + {pluginPreview ? ( + <> + + + + ) : ( + <> + + + + + + )} + +
+
+ )} +
+ + {plugins.length === 0 ? ( +
+

No plugins loaded.

+ {canCreate && ( +

+ Add a plugin to enable external account authentication. +

+ )} +
+ ) : ( +
+ + + + Plugin + Version + Auth Type + Source + Required Secrets + + + + + {plugins.map((plugin) => ( + + +
+ {plugin.name} + + {plugin.id} + +
+
+ + {plugin.version} + + + {formatAuthType(plugin.auth_type)} + + +
+ + {plugin.source} + + {plugin.url && plugin.source === "url" && ( + + + + )} +
+
+ +
+ {plugin.required_secrets.map((secret) => ( + + {secret.name} + + ))} +
+
+ +
+ {canCreate && plugin.required_secrets?.length > 0 && ( + + )} + {canCreate && plugin.source === "url" && ( + + )} + {canDelete && ( + + )} +
+
+
+ ))} +
+
+
+ )} + +
+

Plugin Configuration

+

+ Configure plugin secrets using the button. + Alternatively, set environment variables like{" "} + PLUGIN_STEAM_API_KEY. +

+

+ Dashboard-configured secrets take precedence over environment variables. +

+
+ + {/* Configure Secrets Dialog */} + + + + + Configure {configPlugin?.name} + + + Enter the required secrets for this plugin. Leave empty to use environment variables. + + +
+ {configPlugin?.required_secrets.map((secret) => ( +
+ + {secret.description && ( +

{secret.description}

+ )} + + setSecretValues((prev) => ({ ...prev, [secret.key]: e.target.value })) + } + /> +
+ ))} +
+ + + + + + +
+
+
+ + ); +} diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx index 458592f..119345c 100644 --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -56,6 +56,7 @@ const PERMISSION_CATEGORIES: Record = { "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], Backfill: ["backfill:create", "backfill:read"], "Rate Limits": ["rate-limits:read", "rate-limits:create", "rate-limits:delete"], + Plugins: ["plugins:read", "plugins:create", "plugins:delete"], System: ["stats:read", "events:read"], }; @@ -71,7 +72,7 @@ const TEMPLATES = [ const TEMPLATE_PERMISSIONS: Record = { viewer: ["lexicons:read", "records:read", "script-variables:read", "users:read", "api-keys:read", "backfill:read", "stats:read", "events:read"], operator: ["lexicons:read", "records:read", "records:delete", "script-variables:read", "script-variables:create", "users:read", "api-keys:read", "backfill:read", "backfill:create", "stats:read", "events:read"], - manager: ["lexicons:create", "lexicons:read", "lexicons:delete", "records:read", "records:delete", "records:delete-collection", "script-variables:create", "script-variables:read", "script-variables:delete", "users:read", "api-keys:read", "backfill:create", "backfill:read", "stats:read", "events:read"], + manager: ["lexicons:create", "lexicons:read", "lexicons:delete", "records:read", "records:delete", "records:delete-collection", "script-variables:create", "script-variables:read", "script-variables:delete", "users:read", "api-keys:read", "backfill:create", "backfill:read", "stats:read", "events:read", "plugins:read", "plugins:create", "plugins:delete"], full_access: ALL_PERMISSIONS, }; diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 4aa842f..0b605ec 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -15,6 +15,7 @@ import { IconChevronRight, IconShield, IconLink, + IconPuzzle, } from "@tabler/icons-react" import Image from "next/image" import Link from "next/link" @@ -53,6 +54,7 @@ const navItems = [ const settingsSubItems = [ { title: "Users", url: "/dashboard/settings/users", icon: IconUsers, requiredPermissions: ["users:read"] }, { title: "Linked Accounts", url: "/dashboard/settings/accounts", icon: IconLink, requiredPermissions: [] as string[] }, + { title: "Plugins", url: "/dashboard/settings/plugins", icon: IconPuzzle, requiredPermissions: ["plugins:read"] }, { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"] }, { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1a6eafe..5e40689 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -429,3 +429,74 @@ export function connectWithConfig(pluginId: string, config: Record("/admin/plugins") +} + +export function addPlugin(body: { url: string; sha256?: string }) { + return apiFetch("/admin/plugins", { + method: "POST", + body: JSON.stringify(body), + }) +} + +export function removePlugin(id: string) { + return apiFetch(`/admin/plugins/${encodeURIComponent(id)}`, { + method: "DELETE", + }) +} + +export function reloadPlugin(id: string) { + return apiFetch( + `/admin/plugins/${encodeURIComponent(id)}/reload`, + { method: "POST" }, + ) +} + +export interface PluginSecretsResponse { + plugin_id: string + secrets: Record +} + +export function getPluginSecrets(id: string) { + return apiFetch( + `/admin/plugins/${encodeURIComponent(id)}/secrets`, + ) +} + +export function updatePluginSecrets(id: string, secrets: Record) { + return apiFetch( + `/admin/plugins/${encodeURIComponent(id)}/secrets`, + { method: "PUT", body: JSON.stringify({ secrets }) }, + ) +} + +export interface SecretDefinition { + key: string + name: string + description: string | null +} + +export interface PluginPreview { + id: string + name: string + version: string + description: string | null + icon_url: string | null + auth_type: string + required_secrets: SecretDefinition[] + manifest_url: string + wasm_url: string +} + +export function previewPlugin(url: string) { + return apiFetch("/admin/plugins/preview", { + method: "POST", + body: JSON.stringify({ url }), + }) +} diff --git a/web/src/types/plugins.ts b/web/src/types/plugins.ts new file mode 100644 index 0000000..f9ecfe2 --- /dev/null +++ b/web/src/types/plugins.ts @@ -0,0 +1,23 @@ +export interface SecretDefinition { + key: string; + name: string; + description: string | null; +} + +export interface PluginSummary { + id: string; + name: string; + version: string; + source: "file" | "url"; + url: string | null; + sha256: string | null; + enabled: boolean; + auth_type: string; + required_secrets: SecretDefinition[]; + loaded_at: string | null; +} + +export interface PluginsListResponse { + plugins: PluginSummary[]; + encryption_configured: boolean; +} -- 2.51.2