Something went wrong. Try again.
A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343use 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<String>), body: Option<super::types::ReloadPluginBody>,) -> (String, Option<String>) { 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<String>, 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<HashMap<String, String>, 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<Option<String>, AppError> { let sql = adapt_sql( "SELECT publisher_did FROM happyview_plugins WHERE id = ?", state.db_backend, ); let row: Option<(Option<String>,)> = 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<String>, pending_releases: Vec<ReleaseEntry>, /// 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<String>,}
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<ReleaseEntry> = 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<String>, dependencies: Vec<crate::plugin::PluginDependency>, allowed_hosts: Vec<String>, 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<String>, icon_url: Option<String>, banner_url: Option<String>,}
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<AppState>, auth: UserAuth, Query(q): Query<ListPluginsQuery>,) -> Result<Json<PluginsListResponse>, 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<String> = { let sql = adapt_sql( "SELECT plugin_id FROM happyview_plugin_configs WHERE config IS NOT NULL", state.db_backend, ); crate::db::query_scalar::<String>(&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<PluginSummary> = 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<super::types::SecretDefinition> = 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<AppState>, auth: UserAuth, Json(body): Json<PreviewPluginBody>,) -> Result<Json<PluginPreviewResponse>, 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<Vec<String>>,) -> Result<Option<Vec<String>>, 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<String> = effective.iter().map(|c| c.as_str().to_string()).collect(); names.sort(); Ok(Some(names))}
/// POST /admin/plugins - add a new plugin from URLpub(super) async fn add( State(state): State<AppState>, auth: UserAuth, Json(body): Json<AddPluginBody>,) -> Result<Json<PluginSummary>, 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<super::types::SecretDefinition> = 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 pluginpub(super) async fn remove( State(state): State<AppState>, auth: UserAuth, Path(plugin_id): Path<String>, Query(q): Query<RemovePluginQuery>,) -> Result<axum::response::Response, AppError> { 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 sourcepub(super) async fn reload( State(state): State<AppState>, auth: UserAuth, Path(plugin_id): Path<String>, body: Option<Json<super::types::ReloadPluginBody>>,) -> Result<Json<PluginSummary>, 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<super::types::SecretDefinition> = 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::<i32>(&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<AppState>, auth: UserAuth, Path(plugin_id): Path<String>,) -> Result<Json<PluginSecretsResponse>, 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<String, String> = 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 secretspub(super) async fn update_secrets( State(state): State<AppState>, auth: UserAuth, Path(plugin_id): Path<String>, Json(body): Json<UpdatePluginSecretsBody>,) -> Result<StatusCode, AppError> { 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<String, String> = 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` pluginpub(super) async fn get_allowed_hosts( State(state): State<AppState>, auth: UserAuth, Path(plugin_id): Path<String>,) -> Result<Json<PluginAllowedHostsResponse>, 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 listpub(super) async fn update_allowed_hosts( State(state): State<AppState>, auth: UserAuth, Path(plugin_id): Path<String>, Json(body): Json<UpdatePluginAllowedHostsBody>,) -> Result<Json<PluginAllowedHostsResponse>, 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 pluginpub(super) async fn check_update( State(state): State<AppState>, auth: UserAuth, Path(plugin_id): Path<String>,) -> Result<Json<PluginSummary>, 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<super::types::SecretDefinition> = 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 cachepub(super) async fn list_official( State(state): State<AppState>, auth: UserAuth,) -> Result<Json<super::types::OfficialPluginsListResponse>, 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::<Vec<_>>();
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")); }}