use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use sha2::{Digest, Sha256}; 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::capabilities; use crate::plugin::encryption::{decrypt, encrypt}; use crate::plugin::graph::GraphError; use crate::plugin::loader; use crate::plugin::official_registry::{OfficialPlugin, ReleaseEntry}; use crate::plugin::{LoadedPlugin, PluginType}; /// If the reload request provides a new URL, use it and clear the old sha256 /// (the new version has its own hash). Otherwise keep the stored values. fn resolve_reload_url( current: (String, Option), body: Option, ) -> (String, Option) { match body { Some(b) if b.url.is_some() => (b.url.unwrap(), None), _ => current, } } /// Whose updates an installed plugin may be offered (see `official_registry`). enum InstalledPublisher { /// The publisher of the catalogue entry it was installed from. Recorded(String), /// Installed from a URL the catalogue did not offer, or before the /// publisher was recorded: judged by whether its stored manifest URL lies /// under the official repo's releases. Unrecorded { official_url: bool }, } impl InstalledPublisher { fn of( state: &AppState, recorded: Option, source: &crate::plugin::PluginSource, ) -> Self { if let Some(did) = recorded { return Self::Recorded(did); } let official_url = match source { crate::plugin::PluginSource::Url { url, .. } => { crate::plugin::official_registry::is_official_manifest_url( &state.official_registry_config, url, ) } crate::plugin::PluginSource::File { .. } => false, }; Self::Unrecorded { official_url } } fn may_be_updated_by(&self, entry: &OfficialPlugin) -> bool { match self { Self::Recorded(did) => { crate::plugin::official_registry::effective_publisher(entry) == Some(did.as_str()) } Self::Unrecorded { official_url } => entry.official || !official_url, } } } /// The publisher each installed plugin recorded, by id; ids that recorded /// none are absent. async fn recorded_publishers(state: &AppState) -> Result, AppError> { let sql = adapt_sql( "SELECT id, publisher_did FROM happyview_plugins WHERE publisher_did IS NOT NULL", state.db_backend, ); let rows: Vec<(String, String)> = crate::db::query_as(&sql) .fetch_all(&state.db) .await .map_err(|e| AppError::Internal(format!("Failed to read plugin publishers: {e}")))?; Ok(rows.into_iter().collect()) } /// The publisher one installed plugin recorded, if any. async fn recorded_publisher(state: &AppState, plugin_id: &str) -> Result, AppError> { let sql = adapt_sql( "SELECT publisher_did FROM happyview_plugins WHERE id = ?", state.db_backend, ); let row: Option<(Option,)> = crate::db::query_as(&sql) .bind(plugin_id) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("Failed to read plugin publisher: {e}")))?; Ok(row.and_then(|(publisher,)| publisher)) } /// Record the publisher of the catalogue entry this plugin was installed /// from, or clear it when the entry under its id does not offer `url`. /// Derived here, never taken from the request, so a caller cannot claim an /// install is someone else's. async fn record_publisher(state: &AppState, plugin_id: &str, url: &str) -> Result<(), AppError> { let publisher = crate::plugin::official_registry::publisher_for_install( &state.official_registry_config, &*state.official_registry.read().await, plugin_id, url, ); let sql = adapt_sql( "UPDATE happyview_plugins SET publisher_did = ? WHERE id = ?", state.db_backend, ); crate::db::query(&sql) .bind(publisher) .bind(plugin_id) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("Failed to record plugin publisher: {e}")))?; Ok(()) } struct UpdateInfo { update_available: bool, latest_version: Option, pending_releases: Vec, /// The approved entry's manifest, so an update installs the entry the /// gate passed rather than whatever the catalogue holds by the time it /// is applied. latest_manifest_url: Option, } fn compute_update_info( installed_version: &str, installed: &InstalledPublisher, cache_entry: Option<&OfficialPlugin>, ) -> UpdateInfo { let Some(entry) = cache_entry.filter(|e| installed.may_be_updated_by(e)) else { return UpdateInfo { update_available: false, latest_version: None, pending_releases: Vec::new(), latest_manifest_url: None, }; }; let installed = match semver::Version::parse(installed_version) { Ok(v) => v, Err(_) => { return UpdateInfo { update_available: false, latest_version: Some(entry.latest_version.clone()), pending_releases: Vec::new(), latest_manifest_url: Some(entry.manifest_url.clone()), }; } }; let pending: Vec = entry .releases .iter() .filter(|r| { semver::Version::parse(&r.version) .map(|v| v > installed) .unwrap_or(false) }) .cloned() .collect(); UpdateInfo { update_available: !pending.is_empty(), latest_version: Some(entry.latest_version.clone()), pending_releases: pending, latest_manifest_url: Some(entry.manifest_url.clone()), } } use super::auth::UserAuth; use super::permissions::Permission; use super::types::{ AddPluginBody, ListPluginsQuery, PluginAllowedHostsResponse, PluginPreviewResponse, PluginSecretsResponse, PluginSummary, PluginsListResponse, PreviewPluginBody, RemovePluginQuery, UpdatePluginAllowedHostsBody, UpdatePluginSecretsBody, }; /// The type/namespace/dependency/host/capability fields shared by every /// `PluginSummary` and `PluginPreviewResponse` response. struct CapabilityFields { plugin_type: String, namespace: Option, dependencies: Vec, allowed_hosts: Vec, capabilities: capabilities::CapabilityReport, } /// What a surface shows about a plugin before it shows what it does. /// Gathered here so every summary reads the same source. struct PresentationFields { description: Option, icon_url: Option, banner_url: Option, } fn presentation_fields(p: &LoadedPlugin) -> PresentationFields { PresentationFields { description: p.description().map(String::from), icon_url: p.icon_url().map(String::from), banner_url: p.banner_url().map(String::from), } } fn capability_fields(p: &LoadedPlugin) -> CapabilityFields { let report = capabilities::report(p).unwrap_or_else(|e| capabilities::CapabilityReport { declared: Vec::new(), required_by_imports: Vec::new(), undeclared: vec![e], }); CapabilityFields { plugin_type: p.plugin_type().as_str().to_string(), namespace: p.namespace().map(String::from), dependencies: p.dependencies().to_vec(), allowed_hosts: p.allowed_hosts().to_vec(), capabilities: report, } } /// GET /admin/plugins - list all loaded plugins, optionally filtered by /// `?type=library|interpreter|auth`. pub(super) async fn list( State(state): State, auth: UserAuth, Query(q): Query, ) -> Result, AppError> { auth.require(Permission::PluginsRead).await?; let plugins = if let Some(type_str) = &q.plugin_type { let t = PluginType::parse_str(type_str) .ok_or_else(|| AppError::BadRequest(format!("Unknown plugin type '{}'", type_str)))?; state.plugin_registry.list_by_type(t).await } else { state.plugin_registry.list().await }; // Query which plugins have secrets configured let configured_plugins: std::collections::HashSet = { let sql = adapt_sql( "SELECT plugin_id FROM happyview_plugin_configs WHERE config IS NOT NULL", state.db_backend, ); crate::db::query_scalar::(&sql) .fetch_all(&state.db) .await .unwrap_or_default() .into_iter() .collect() }; let publishers = recorded_publishers(&state).await?; let official_guard = state.official_registry.read().await; let summaries: Vec = plugins .into_iter() .map(|p| { let (source, url, sha256) = match &p.source { crate::plugin::PluginSource::File { path } => { ("file".to_string(), Some(path.display().to_string()), None) } crate::plugin::PluginSource::Url { url, sha256 } => { ("url".to_string(), Some(url.clone()), sha256.clone()) } }; // Use manifest for rich secret metadata if available, otherwise fallback to basic keys let required_secrets: Vec = 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 { // No manifest (the loader always attaches one; this only // covers a plugin registered directly, bypassing it) - // fall back to minimal SecretDefinitions from the keys. p.info .required_secrets .iter() .map(|key| super::types::SecretDefinition { key: key.clone(), name: key.clone(), // No richer name available description: None, }) .collect() }; // Plugin is configured if it has no required secrets OR has a config entry let secrets_configured = required_secrets.is_empty() || configured_plugins.contains(&p.info.id); let update_info = compute_update_info( &p.info.version, &InstalledPublisher::of(&state, publishers.get(&p.info.id).cloned(), &p.source), official_guard.plugins.get(&p.info.id), ); let cap_fields = capability_fields(&p); let pres = presentation_fields(&p); PluginSummary { id: p.info.id.clone(), name: p.info.name.clone(), version: p.info.version.clone(), description: pres.description, icon_url: pres.icon_url, banner_url: pres.banner_url, source, url, sha256, enabled: true, // Currently all loaded plugins are enabled auth_type: p.info.auth_type.clone(), required_secrets, secrets_configured, loaded_at: None, // Would need to track this in registry update_available: update_info.update_available, latest_version: update_info.latest_version, pending_releases: update_info.pending_releases, latest_manifest_url: update_info.latest_manifest_url, plugin_type: cap_fields.plugin_type, namespace: cap_fields.namespace, dependencies: cap_fields.dependencies, allowed_hosts: cap_fields.allowed_hosts, capabilities: cap_fields.capabilities, } }) .collect(); 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)))?; // Download the WASM too, so the capability report and sha256 reflect // what would actually be installed. A `CapabilityMismatch` or // `UnknownImport` here is surfaced verbatim: it's the reason the // plugin cannot be installed. let plugin = loader::load_from_manifest(&state.http, &preview, None) .await .map_err(|e| AppError::BadRequest(e.to_string()))?; let cap_fields = capability_fields(&plugin); let pres = presentation_fields(&plugin); let sha256 = hex::encode(Sha256::digest(&plugin.wasm_bytes)); Ok(Json(PluginPreviewResponse { id: preview.manifest.id, name: preview.manifest.name, version: preview.manifest.version, description: pres.description, icon_url: pres.icon_url, banner_url: pres.banner_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, plugin_type: cap_fields.plugin_type, namespace: cap_fields.namespace, dependencies: cap_fields.dependencies, allowed_hosts: cap_fields.allowed_hosts, capabilities: cap_fields.capabilities, sha256, })) } /// If `accepted` was supplied, every capability `plugin` would actually be /// granted must be covered by it — used identically by `add` and `reload` so /// a reload can't silently bring in an import the operator never consented /// to. Returns the sorted, deduped capability names (for the event log /// detail) when a check was performed, or `None` when `accepted` was omitted /// (consent checking is opt-in on the wire). fn check_capability_consent( plugin: &LoadedPlugin, accepted: &Option>, ) -> Result>, AppError> { let Some(accepted) = accepted else { return Ok(None); }; let effective = capabilities::effective_set(plugin).map_err(AppError::BadRequest)?; let missing: Vec<&str> = effective .iter() .map(|c| c.as_str()) .filter(|name| !accepted.iter().any(|a| a == name)) .collect(); if !missing.is_empty() { return Err(AppError::BadRequest(format!( "capability consent missing: {}", missing.join(", ") ))); } let mut names: Vec = effective.iter().map(|c| c.as_str().to_string()).collect(); names.sort(); Ok(Some(names)) } /// POST /admin/plugins - add a new plugin from URL pub(super) async fn add( State(state): State, auth: UserAuth, Json(body): Json, ) -> Result, AppError> { 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)))?; 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)))?; // If the caller told us which capabilities they consent to, every // capability the plugin would actually be granted must be covered. let consented_capabilities = check_capability_consent(&plugin, &body.accepted_capabilities)?; // Use manifest for rich secret metadata if available let required_secrets: Vec = 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() }; // Newly added plugins are not configured (unless they have no required secrets) let secrets_configured = required_secrets.is_empty(); let cap_fields = capability_fields(&plugin); let pres = presentation_fields(&plugin); let summary = PluginSummary { id: plugin.info.id.clone(), name: plugin.info.name.clone(), version: plugin.info.version.clone(), description: pres.description, icon_url: pres.icon_url, banner_url: pres.banner_url, source: "url".to_string(), url: Some(body.url.clone()), sha256: body.sha256.clone(), enabled: true, auth_type: plugin.info.auth_type.clone(), required_secrets, secrets_configured, loaded_at: Some(now_rfc3339()), update_available: false, latest_version: None, pending_releases: Vec::new(), latest_manifest_url: None, plugin_type: cap_fields.plugin_type, namespace: cap_fields.namespace, dependencies: cap_fields.dependencies, allowed_hosts: cap_fields.allowed_hosts, capabilities: cap_fields.capabilities, }; let plugin_id = plugin.info.id.clone(); // Validate against the dependency graph and register (this also // persists to DB). state .plugin_registry .install(plugin) .await .map_err(|e| AppError::BadRequest(e.to_string()))?; // The plugin is installed whatever happens here. A row left without a // publisher is judged by its URL instead, which is weaker but not open, // so a failed write is not worth failing the install over. if let Err(e) = record_publisher(&state, &plugin_id, &body.url).await { tracing::warn!(plugin = %plugin_id, error = ?e, "plugin installed without a recorded publisher"); } let mut detail = serde_json::json!({ "url": body.url }); if let Some(caps) = consented_capabilities { detail["capabilities"] = serde_json::json!(caps); } log_event( &state.db, EventLog { event_type: "plugin.added".to_string(), severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(plugin_id), detail, }, state.db_backend, ) .await; Ok(Json(summary)) } /// DELETE /admin/plugins/{id} - remove a plugin pub(super) async fn remove( State(state): State, auth: UserAuth, Path(plugin_id): Path, Query(q): Query, ) -> Result { auth.require(Permission::PluginsDelete).await?; // Validate against the dependency graph (unless forced) and remove from // the registry. `removed` is dependents-first: for a forced cascade, // everything that depended on `plugin_id` comes out before it does. let removed = match state.plugin_registry.uninstall(&plugin_id, q.force).await { Ok(ids) if ids.is_empty() => { return Err(AppError::NotFound(format!( "Plugin '{}' not found", plugin_id ))); } Ok(ids) => ids, Err(GraphError::HasDependents { dependents, .. }) => { return Ok(( StatusCode::CONFLICT, Json(serde_json::json!({ "error": format!( "plugin '{plugin_id}' is required by: {}", dependents.join(", ") ), "dependents": dependents, })), ) .into_response()); } Err(e) => return Err(AppError::BadRequest(e.to_string())), }; // Remove each id from the database and log one event per id. for id in &removed { let sql = adapt_sql( "DELETE FROM happyview_plugins WHERE id = ?", state.db_backend, ); crate::db::query(&sql) .bind(id) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("Failed to delete plugin: {}", e)))?; log_event( &state.db, EventLog { event_type: "plugin.removed".to_string(), severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(id.clone()), detail: serde_json::json!({}), }, state.db_backend, ) .await; } if removed == [plugin_id] { Ok(StatusCode::NO_CONTENT.into_response()) } else { Ok(( StatusCode::OK, Json(serde_json::json!({ "removed": removed })), ) .into_response()) } } /// POST /admin/plugins/{id}/reload - reload a plugin from its source pub(super) async fn reload( State(state): State, auth: UserAuth, Path(plugin_id): Path, body: Option>, ) -> Result, AppError> { auth.require(Permission::PluginsCreate).await?; // Get current plugin to find its source let current = state .plugin_registry .get(&plugin_id) .await .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; let (url, sha256) = match ¤t.source { crate::plugin::PluginSource::Url { url, sha256 } => (url.clone(), sha256.clone()), crate::plugin::PluginSource::File { .. } => { return Err(AppError::BadRequest( "Cannot reload file-based plugins via API".into(), )); } }; let body = body.map(|Json(b)| b); let accepted_capabilities = body.as_ref().and_then(|b| b.accepted_capabilities.clone()); let installed_url = url.clone(); let (url, sha256) = resolve_reload_url((url, sha256), body); // A recorded install stays with its publisher: a new URL must be one the // catalogue offers under this id from that same publisher. Without this // the update gate decides what is offered and anything else decides what // is installed. An unrecorded install has no publisher to hold to. if url != installed_url && let Some(recorded) = recorded_publisher(&state, &plugin_id).await? { let offered = crate::plugin::official_registry::publisher_for_install( &state.official_registry_config, &*state.official_registry.read().await, &plugin_id, &url, ); if offered.as_deref() != Some(recorded.as_str()) { return Err(AppError::Conflict(format!( "'{plugin_id}' was installed from {recorded}'s package, and this URL is not \ one that publisher offers. Remove the plugin and add it again to install \ another publisher's." ))); } } // 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)))?; // Same consent check `add` performs, so a reloaded version can't bring in // a capability the operator never agreed to. Checked before the old // plugin is removed, so a refusal here leaves the currently-installed // version untouched rather than uninstalling it and then failing to // reinstall. let consented_capabilities = check_capability_consent(&plugin, &accepted_capabilities)?; // Remove old plugin state.plugin_registry.remove(&plugin_id).await; // Use manifest for rich secret metadata if available let required_secrets: Vec = 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() }; // Check if reloaded plugin still has its config let secrets_configured = required_secrets.is_empty() || { let sql = adapt_sql( "SELECT 1 FROM happyview_plugin_configs WHERE plugin_id = ?", state.db_backend, ); crate::db::query_scalar::(&sql) .bind(&plugin.info.id) .fetch_optional(&state.db) .await .ok() .flatten() .is_some() }; let cap_fields = capability_fields(&plugin); let pres = presentation_fields(&plugin); let summary = PluginSummary { id: plugin.info.id.clone(), name: plugin.info.name.clone(), version: plugin.info.version.clone(), description: pres.description, icon_url: pres.icon_url, banner_url: pres.banner_url, source: "url".to_string(), url: Some(url.clone()), sha256, enabled: true, auth_type: plugin.info.auth_type.clone(), required_secrets, secrets_configured, loaded_at: Some(now_rfc3339()), update_available: false, latest_version: None, pending_releases: Vec::new(), latest_manifest_url: None, plugin_type: cap_fields.plugin_type, namespace: cap_fields.namespace, dependencies: cap_fields.dependencies, allowed_hosts: cap_fields.allowed_hosts, capabilities: cap_fields.capabilities, }; // Persist the (possibly new) URL so restarts pick it up let persist_sql = adapt_sql( "UPDATE happyview_plugins SET url = ?, sha256 = NULL WHERE id = ?", state.db_backend, ); crate::db::query(&persist_sql) .bind(&url) .bind(&plugin.info.id) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("Failed to persist plugin URL: {}", e)))?; // A new URL may come from a different catalogue entry, so its publisher is // derived afresh; reloading the same URL keeps the recorded one. if url != installed_url { record_publisher(&state, &plugin_id, &url).await?; } // Validate against the dependency graph and register the reloaded plugin state .plugin_registry .install(plugin) .await .map_err(|e| AppError::BadRequest(e.to_string()))?; let mut detail = serde_json::json!({ "url": url }); if let Some(caps) = consented_capabilities { detail["capabilities"] = serde_json::json!(caps); } log_event( &state.db, EventLog { event_type: "plugin.reloaded".to_string(), severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(plugin_id), detail, }, state.db_backend, ) .await; 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 happyview_plugin_configs WHERE plugin_id = ?", state.db_backend, ); let row: Option<(String,)> = crate::db::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 happyview_plugin_configs WHERE plugin_id = ?", state.db_backend, ); let row: Option<(String,)> = crate::db::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 happyview_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, ); crate::db::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) } /// A plugin's `allowed_hosts` only means anything under /// `network:request:defined` — the operator has nowhere else to point it, /// since `network:request` takes its list from the manifest and /// `network:request:unrestricted` needs none. Both allowed-hosts endpoints /// refuse a plugin that hasn't declared it, the same way they refuse one /// that doesn't exist. fn require_network_request_defined(plugin: &LoadedPlugin) -> Result<(), AppError> { if plugin .declared_capabilities() .contains(&capabilities::PluginCapability::NetworkRequestDefined) { Ok(()) } else { Err(AppError::BadRequest(format!( "plugin '{}' does not declare network:request:defined", plugin.info.id ))) } } /// GET /admin/plugins/{id}/allowed-hosts - operator-configured hosts for a /// `network:request:defined` plugin pub(super) async fn get_allowed_hosts( State(state): State, auth: UserAuth, Path(plugin_id): Path, ) -> Result, AppError> { auth.require(Permission::PluginsRead).await?; let plugin = state .plugin_registry .get(&plugin_id) .await .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; require_network_request_defined(&plugin)?; let hosts = crate::plugin::config::load_allowed_hosts(&state.db, state.db_backend, &plugin_id) .await .map_err(|e| AppError::Internal(format!("Failed to fetch config: {}", e)))?; Ok(Json(PluginAllowedHostsResponse { hosts })) } /// PUT /admin/plugins/{id}/allowed-hosts - replace the operator-configured /// hosts for a `network:request:defined` plugin, returning the stored list pub(super) async fn update_allowed_hosts( State(state): State, auth: UserAuth, Path(plugin_id): Path, Json(body): Json, ) -> Result, AppError> { auth.require(Permission::PluginsCreate).await?; let plugin = state .plugin_registry .get(&plugin_id) .await .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; require_network_request_defined(&plugin)?; crate::plugin::config::store_allowed_hosts( &state.db, state.db_backend, &plugin_id, &body.hosts, ) .await?; log_event( &state.db, EventLog { event_type: "plugin.allowed_hosts_updated".to_string(), severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(plugin_id), detail: serde_json::json!({ "allowed_hosts": &body.hosts }), }, state.db_backend, ) .await; Ok(Json(PluginAllowedHostsResponse { hosts: body.hosts })) } /// POST /admin/plugins/{id}/check-update — force a cache refresh for one plugin pub(super) async fn check_update( State(state): State, auth: UserAuth, Path(plugin_id): Path, ) -> Result, AppError> { auth.require(Permission::PluginsCreate).await?; crate::plugin::official_registry::refresh_plugin( &state.http, &state.official_registry_config, &state.official_registry, &plugin_id, ) .await .map_err(|e| AppError::BadRequest(format!("Update check failed: {}", e)))?; // Return the refreshed PluginSummary by re-running the same join logic let current = state .plugin_registry .get(&plugin_id) .await .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; let recorded = recorded_publisher(&state, &plugin_id).await?; let guard = state.official_registry.read().await; let update_info = compute_update_info( ¤t.info.version, &InstalledPublisher::of(&state, recorded, ¤t.source), guard.plugins.get(&plugin_id), ); let required_secrets: Vec = if let Some(manifest) = ¤t.manifest { manifest .required_secrets .iter() .map(|s| super::types::SecretDefinition { key: s.key.clone(), name: s.name.clone(), description: s.description.clone(), }) .collect() } else { current .info .required_secrets .iter() .map(|key| super::types::SecretDefinition { key: key.clone(), name: key.clone(), description: None, }) .collect() }; let (source, url, sha256) = match ¤t.source { crate::plugin::PluginSource::File { path } => { ("file".to_string(), Some(path.display().to_string()), None) } crate::plugin::PluginSource::Url { url, sha256 } => { ("url".to_string(), Some(url.clone()), sha256.clone()) } }; let cap_fields = capability_fields(¤t); let pres = presentation_fields(¤t); Ok(Json(PluginSummary { id: current.info.id.clone(), name: current.info.name.clone(), version: current.info.version.clone(), description: pres.description, icon_url: pres.icon_url, banner_url: pres.banner_url, source, url, sha256, enabled: true, auth_type: current.info.auth_type.clone(), required_secrets, secrets_configured: true, loaded_at: None, update_available: update_info.update_available, latest_version: update_info.latest_version, pending_releases: update_info.pending_releases, latest_manifest_url: update_info.latest_manifest_url, plugin_type: cap_fields.plugin_type, namespace: cap_fields.namespace, dependencies: cap_fields.dependencies, allowed_hosts: cap_fields.allowed_hosts, capabilities: cap_fields.capabilities, })) } /// GET /admin/plugins/official — list plugins from the official registry cache pub(super) async fn list_official( State(state): State, auth: UserAuth, ) -> Result, AppError> { auth.require(Permission::PluginsRead).await?; let guard = state.official_registry.read().await; let plugins = guard .plugins .values() .map(|p| super::types::OfficialPluginSummary { id: p.id.clone(), name: p.name.clone(), description: p.description.clone(), icon_url: p.icon_url.clone(), latest_version: p.latest_version.clone(), manifest_url: p.manifest_url.clone(), official: p.official, banner_url: p.banner_url.clone(), releases: p.releases.clone(), sections: p.sections.clone(), }) .collect::>(); Ok(Json(super::types::OfficialPluginsListResponse { plugins, last_refreshed_at: guard.last_refreshed_at.clone(), })) } #[cfg(test)] mod tests { use super::*; use crate::plugin::official_registry::{OfficialPlugin, ReleaseEntry}; fn entry(versions: &[&str]) -> OfficialPlugin { OfficialPlugin { id: "steam".into(), publisher_did: None, official: true, name: "steam".into(), description: None, icon_url: None, latest_version: versions[0].into(), manifest_url: "m".into(), wasm_url: "w".into(), releases: versions .iter() .map(|v| ReleaseEntry { version: (*v).into(), name: format!("v{v}"), published_at: "2026-04-10T00:00:00Z".into(), body: "notes".into(), }) .collect(), sections: Default::default(), banner_url: None, } } #[test] fn compute_update_info_flags_update_when_behind() { let cached = entry(&["1.2.0", "1.1.0", "1.0.0"]); let info = compute_update_info("1.0.0", &unrecorded(false), Some(&cached)); assert!(info.update_available); assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); assert_eq!(info.pending_releases.len(), 2); assert_eq!(info.pending_releases[0].version, "1.2.0"); assert_eq!(info.pending_releases[1].version, "1.1.0"); } #[test] fn compute_update_info_no_update_when_current() { let cached = entry(&["1.2.0"]); let info = compute_update_info("1.2.0", &unrecorded(false), Some(&cached)); assert!(!info.update_available); assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); assert!(info.pending_releases.is_empty()); } fn unrecorded(official_url: bool) -> InstalledPublisher { InstalledPublisher::Unrecorded { official_url } } fn stranger(versions: &[&str]) -> OfficialPlugin { OfficialPlugin { official: false, publisher_did: Some("did:plc:someoneelse".into()), ..entry(versions) } } /// An install is offered only its own publisher's releases: an official /// install never sees a stranger's entry under its id, and a stranger's /// install never sees the official one. #[test] fn an_install_is_only_updated_by_its_own_publisher() { let official = InstalledPublisher::Recorded( crate::plugin::official_registry::OFFICIAL_PUBLISHER_DID.into(), ); let theirs = InstalledPublisher::Recorded("did:plc:someoneelse".into()); let third = InstalledPublisher::Recorded("did:plc:third".into()); assert!(compute_update_info("1.0.0", &official, Some(&entry(&["1.2.0"]))).update_available); let info = compute_update_info("1.0.0", &official, Some(&stranger(&["9.0.0"]))); assert!(!info.update_available); assert!(info.latest_version.is_none()); assert!( compute_update_info("1.0.0", &theirs, Some(&stranger(&["9.0.0"]))).update_available ); assert!(!compute_update_info("1.0.0", &theirs, Some(&entry(&["1.2.0"]))).update_available); assert!( !compute_update_info("1.0.0", &third, Some(&stranger(&["9.0.0"]))).update_available ); } /// A row that recorded no publisher keeps the URL rule: from the official /// repo's releases, only official entries; from anywhere else, any. #[test] fn an_unrecorded_install_falls_back_to_its_url() { assert!( !compute_update_info("1.0.0", &unrecorded(true), Some(&stranger(&["9.0.0"]))) .update_available ); assert!( compute_update_info("1.0.0", &unrecorded(true), Some(&entry(&["1.2.0"]))) .update_available ); assert!( compute_update_info("1.0.0", &unrecorded(false), Some(&stranger(&["9.0.0"]))) .update_available ); assert!( compute_update_info("1.0.0", &unrecorded(false), Some(&entry(&["1.2.0"]))) .update_available ); } #[test] fn compute_update_info_no_cache_entry() { let info = compute_update_info("1.2.0", &unrecorded(false), None); assert!(!info.update_available); assert!(info.latest_version.is_none()); assert!(info.pending_releases.is_empty()); } #[test] fn compute_update_info_handles_malformed_installed_version() { let cached = entry(&["1.2.0"]); let info = compute_update_info("not-semver", &unrecorded(false), Some(&cached)); assert!(!info.update_available); assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); } #[test] fn resolve_reload_url_uses_override_and_clears_sha() { let current = ("https://old".to_string(), Some("deadbeef".to_string())); let body = super::super::types::ReloadPluginBody { url: Some("https://new".into()), ..Default::default() }; let (url, sha) = resolve_reload_url(current, Some(body)); assert_eq!(url, "https://new"); assert_eq!(sha, None); } #[test] fn resolve_reload_url_keeps_current_when_body_absent() { let current = ("https://old".to_string(), Some("deadbeef".to_string())); let (url, sha) = resolve_reload_url(current, None); assert_eq!(url, "https://old"); assert_eq!(sha.as_deref(), Some("deadbeef")); } #[test] fn resolve_reload_url_keeps_current_when_body_url_is_none() { let current = ("https://old".to_string(), Some("deadbeef".to_string())); let body = super::super::types::ReloadPluginBody::default(); let (url, sha) = resolve_reload_url(current, Some(body)); assert_eq!(url, "https://old"); assert_eq!(sha.as_deref(), Some("deadbeef")); } }