//! Plugin registry (plugin-ui-surface design): an in-memory, lease-based directory of running //! `punktfunk-plugin-*` processes and the loopback UI each one serves. //! //! A plugin (an out-of-process script under the scripting runner, RFC §8) that wants a UI serves //! it on a **loopback** port behind a per-boot secret, then **registers** here — `{title, ui:{port, //! secret, icon}}` — over the admin/loopback lane it already holds via the SDK. The web console //! reads [`list_plugins`] to grow a nav entry and reverse-proxies to the port (fetching the secret //! from [`get_ui_credential`] server-side, never exposing it to the browser). The host itself never //! dials the plugin, never health-checks it, and never persists any of this: it is a phone book with //! expiry. //! //! Lease model (design §3, D8): a registration lives for [`LEASE_TTL`]; the plugin renews with the //! same idempotent `PUT` every 30 s. Expiry is **lazy** — a crashed plugin's entry simply stops //! listing once stale; there is no reaper task and nothing to persist across a host restart (the //! supervised plugin re-registers on its next tick). Every consumer dials `127.0.0.1:` only — //! a registration stores a *port*, never an address, so it can never point the proxy elsewhere (D5). //! //! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`] //! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every //! other mutation. LAN clients have no business here. use super::shared::*; use crate::events::{emit, EventKind}; use std::collections::HashMap; use std::sync::{OnceLock, RwLock}; use std::time::{Duration, Instant}; /// How long a registration stays live after its last renewal. The SDK helper renews every 30 s, so /// this tolerates two missed ticks before a plugin drops out of the listing. const LEASE_TTL: Duration = Duration::from_secs(90); // ---------------------------------------------------------------- wire shapes /// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request /// body, never a response ([`PluginUiPublic`] is the secret-free view). #[derive(Deserialize, ToSchema)] pub(crate) struct PluginUi { /// The **loopback** port the plugin serves its UI on. The host and console only ever dial /// `127.0.0.1:`; a registration can never carry a hostname. pub port: u16, /// Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every /// request to the plugin's UI server. Rotated whenever the plugin restarts. pub secret: String, /// Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon: Option, } /// Register/renew body for `PUT /plugins/{id}`. #[derive(Deserialize, ToSchema)] pub(crate) struct PluginRegistration { /// Human-readable title for the console nav entry (1–64 chars; control chars stripped). pub title: String, /// Optional plugin version, purely informational (≤32 chars). #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, /// Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book /// entry only (e.g. a future runner-management listing) and grows no nav entry. #[serde(default, skip_serializing_if = "Option::is_none")] pub ui: Option, } /// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser. #[derive(Serialize, ToSchema)] pub(crate) struct PluginUiPublic { pub port: u16, #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, } /// One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists /// and has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy). #[derive(Serialize, ToSchema)] pub(crate) struct PluginSummary { pub id: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ui: Option, } /// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback). /// This is the only endpoint that returns a secret; the console BFF denylists it from the browser. #[derive(Serialize, ToSchema)] pub(crate) struct UiCredential { pub port: u16, pub secret: String, } // ---------------------------------------------------------------- registry core /// The stored UI surface (internal — parsed + validated, no wire derives). #[derive(Clone, PartialEq)] struct StoredUi { port: u16, secret: String, icon: Option, } /// One live registration. `expires_at` is a **monotonic** [`Instant`] (immune to wall-clock jumps). struct Stored { title: String, version: Option, ui: Option, expires_at: Instant, } impl Stored { /// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves /// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does. fn public_eq(&self, title: &str, version: &Option, ui: &Option) -> bool { self.title == title && self.version == *version && self.ui == *ui } } /// The process-wide plugin registry. pub(crate) struct PluginRegistry { inner: RwLock>, } /// A validated registration ready to store (title trimmed, ui fields checked). struct Valid { title: String, version: Option, ui: Option, } impl PluginRegistry { fn new() -> Self { Self { inner: RwLock::new(HashMap::new()), } } /// Insert or renew `id`. Returns `true` when an operator-visible field changed (new plugin, /// restart, or re-scan) — the signal the caller emits `plugins.changed` on. A pure renewal /// returns `false`. fn upsert(&self, id: &str, v: Valid) -> bool { let expires_at = Instant::now() + LEASE_TTL; let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner()); let changed = match map.get(id) { // An *expired* prior entry counts as a change (it had stopped listing). Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui), None => true, }; map.insert( id.to_string(), Stored { title: v.title, version: v.version, ui: v.ui, expires_at, }, ); changed } /// The current listing (sorted by title, then id), plus the ids of entries that had expired and /// were pruned by this call — the caller emits `plugins.changed` for those. Prunes under the /// write lock so a stale entry is reaped exactly once. fn snapshot(&self) -> (Vec, Vec) { let now = Instant::now(); let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner()); let mut expired: Vec = map .iter() .filter(|(_, s)| now >= s.expires_at) .map(|(id, _)| id.clone()) .collect(); for id in &expired { map.remove(id); } expired.sort(); let mut live: Vec = map .iter() .map(|(id, s)| PluginSummary { id: id.clone(), title: s.title.clone(), version: s.version.clone(), ui: s.ui.as_ref().map(|u| PluginUiPublic { port: u.port, icon: u.icon.clone(), }), }) .collect(); live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id))); (live, expired) } /// The `{port, secret}` for a live plugin's UI, or `None` if unknown/expired/UI-less. Does not /// prune (a read path) — a stale entry is reaped by the next [`snapshot`](Self::snapshot). fn credential(&self, id: &str) -> Option { let map = self.inner.read().unwrap_or_else(|e| e.into_inner()); let s = map.get(id)?; if !s.is_live() { return None; } let ui = s.ui.as_ref()?; Some(UiCredential { port: ui.port, secret: ui.secret.clone(), }) } /// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an /// already-expired or unknown id returns `false` (nothing to announce). fn remove(&self, id: &str) -> bool { let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner()); map.remove(id).is_some_and(|s| s.is_live()) } } impl Stored { fn is_live(&self) -> bool { Instant::now() < self.expires_at } } /// The process-wide registry singleton (the [`crate::events::bus`] shape). pub(crate) fn registry() -> &'static PluginRegistry { static REG: OnceLock = OnceLock::new(); REG.get_or_init(PluginRegistry::new) } // ---------------------------------------------------------------- validation /// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK /// enforces, so a plugin's registration id always matches its package name. fn valid_plugin_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.as_bytes()[0].is_ascii_lowercase() && id .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') } /// Strip control characters (defense against a title/version smuggling terminal escapes or newlines /// into a log line or the console nav), then trim. fn sanitize(s: &str) -> String { s.chars() .filter(|c| !c.is_control()) .collect::() .trim() .to_string() } /// Validate a registration body into the internal [`Valid`] form, or a human-readable reason. fn validate(reg: PluginRegistration) -> Result { let title = sanitize(®.title); if title.is_empty() { return Err("title must not be empty".into()); } if title.chars().count() > 64 { return Err("title must be at most 64 characters".into()); } let version = match reg.version { Some(v) => { let v = sanitize(&v); if v.chars().count() > 32 { return Err("version must be at most 32 characters".into()); } (!v.is_empty()).then_some(v) } None => None, }; let ui = match reg.ui { Some(u) => Some(validate_ui(u)?), None => None, }; Ok(Valid { title, version, ui }) } fn validate_ui(u: PluginUi) -> Result { if u.port < 1024 { return Err("ui.port must be a non-privileged port (>= 1024)".into()); } let n = u.secret.len(); if !(16..=128).contains(&n) { return Err("ui.secret must be 16–128 characters".into()); } if !u .secret .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { return Err("ui.secret must be [A-Za-z0-9_-]".into()); } let icon = match u.icon { Some(icon) => { let ok = (1..=48).contains(&icon.len()) && icon .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); if !ok { return Err("ui.icon must be a lucide name ([a-z0-9-], 1–48 chars)".into()); } Some(icon) } None => None, }; Ok(StoredUi { port: u.port, secret: u.secret, icon, }) } // ---------------------------------------------------------------- handlers /// Register or renew a plugin /// /// Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs /// this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console /// will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed /// (first registration, restart, or re-scan) — a pure renewal is silent. #[utoipa::path( put, path = "/plugins/{id}", tag = "plugins", operation_id = "registerPlugin", params(("id" = String, Path, description = "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)")), request_body = PluginRegistration, responses( (status = NO_CONTENT, description = "Registered / renewed"), (status = BAD_REQUEST, description = "Invalid id or registration", body = ApiError), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), ) )] pub(crate) async fn register_plugin( Path(id): Path, ApiJson(reg): ApiJson, ) -> Response { if !valid_plugin_id(&id) { return api_error( StatusCode::BAD_REQUEST, "invalid plugin id (expected kebab-case `[a-z][a-z0-9-]*`, ≤64)", ); } let valid = match validate(reg) { Ok(v) => v, Err(e) => return api_error(StatusCode::BAD_REQUEST, &e), }; if registry().upsert(&id, valid) { tracing::info!(plugin = %id, "plugin registered"); emit(EventKind::PluginsChanged { id }); } StatusCode::NO_CONTENT.into_response() } /// List registered plugins /// /// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry /// reports its id, title, optional version, and — for plugins that serve one — a UI descriptor /// (loopback port + icon). The console renders these as nav entries and proxies to the port; it /// fetches the secret separately, server-side. #[utoipa::path( get, path = "/plugins", tag = "plugins", operation_id = "listPlugins", responses( (status = OK, description = "Live plugin registrations", body = [PluginSummary]), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), ) )] pub(crate) async fn list_plugins() -> Json> { let (plugins, expired) = registry().snapshot(); // Lazy expiry: an entry that aged out is reaped here (exactly once) and announced, so a consumer // watching the event stream sees the departure even though nothing actively deregistered it. for id in expired { tracing::info!(plugin = %id, "plugin lease expired"); emit(EventKind::PluginsChanged { id }); } Json(plugins) } /// Fetch a plugin UI's proxy credential /// /// Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup. /// Bearer + loopback only (like every mutation), and additionally excluded from the console's browser /// passthrough: the secret never reaches a browser. #[utoipa::path( get, path = "/plugins/{id}/ui-credential", tag = "plugins", operation_id = "getPluginUiCredential", params(("id" = String, Path, description = "The plugin id")), responses( (status = OK, description = "The proxy credential", body = UiCredential), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), (status = NOT_FOUND, description = "No live plugin with that id, or it serves no UI", body = ApiError), ) )] pub(crate) async fn get_ui_credential(Path(id): Path) -> Response { match registry().credential(&id) { Some(cred) => Json(cred).into_response(), None => api_error(StatusCode::NOT_FOUND, "no live plugin UI with that id"), } } /// Deregister a plugin /// /// The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls /// this from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was /// removed. Idempotent — deleting an unknown/expired id is a no-op `204`. #[utoipa::path( delete, path = "/plugins/{id}", tag = "plugins", operation_id = "deregisterPlugin", params(("id" = String, Path, description = "The plugin id")), responses( (status = NO_CONTENT, description = "Deregistered (or already absent)"), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), ) )] pub(crate) async fn delete_plugin(Path(id): Path) -> Response { if registry().remove(&id) { tracing::info!(plugin = %id, "plugin deregistered"); emit(EventKind::PluginsChanged { id }); } StatusCode::NO_CONTENT.into_response() } #[cfg(test)] mod tests { use super::*; fn reg(title: &str, port: u16, secret: &str) -> PluginRegistration { PluginRegistration { title: title.into(), version: None, ui: Some(PluginUi { port, secret: secret.into(), icon: Some("gamepad-2".into()), }), } } const SECRET: &str = "abcdefghijklmnop0123"; // 20 chars, valid alphabet #[test] fn id_validation() { assert!(valid_plugin_id("rom-manager")); assert!(valid_plugin_id("a")); assert!(valid_plugin_id("x9")); assert!(!valid_plugin_id("")); // empty assert!(!valid_plugin_id("9lives")); // must start with a letter assert!(!valid_plugin_id("-lead")); // must start with a letter assert!(!valid_plugin_id("Rom")); // no uppercase assert!(!valid_plugin_id("rom_manager")); // no underscore assert!(!valid_plugin_id(&"a".repeat(65))); // too long } #[test] fn registration_validation() { assert!(validate(reg("ROM Manager", 49321, SECRET)).is_ok()); // control chars stripped from the title let v = validate(PluginRegistration { title: "Ro\u{7}m\n".into(), version: None, ui: None, }) .unwrap(); assert_eq!(v.title, "Rom"); // privileged port rejected assert!(validate(reg("x", 80, SECRET)).is_err()); // short secret rejected assert!(validate(reg("x", 49321, "tooshort")).is_err()); // bad secret alphabet rejected assert!(validate(reg("x", 49321, "bad secret with spaces!!")).is_err()); // empty title rejected assert!(validate(reg(" ", 49321, SECRET)).is_err()); } #[test] fn upsert_reports_change_but_not_renewal() { let r = PluginRegistry::new(); // first registration is a change assert!(r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap())); // identical renewal is not assert!(!r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap())); // a new secret (restart) is a change assert!(r.upsert( "p", validate(reg("Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap() )); // a new title (re-scan) is a change assert!(r.upsert( "p", validate(reg("New Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap() )); } #[test] fn snapshot_lists_secret_free_sorted() { let r = PluginRegistry::new(); r.upsert("zeta", validate(reg("Zeta", 50000, SECRET)).unwrap()); r.upsert("alpha", validate(reg("Alpha", 50001, SECRET)).unwrap()); let (plugins, expired) = r.snapshot(); assert!(expired.is_empty()); assert_eq!( plugins.iter().map(|p| p.id.as_str()).collect::>(), vec!["alpha", "zeta"] // sorted by title ); // the summary type has no secret field at all — check the credential path carries it assert_eq!(r.credential("alpha").unwrap().secret, SECRET); assert_eq!(r.credential("alpha").unwrap().port, 50001); } #[test] fn credential_absent_for_ui_less_and_unknown() { let r = PluginRegistry::new(); r.upsert( "headless", validate(PluginRegistration { title: "Headless".into(), version: None, ui: None, }) .unwrap(), ); assert!(r.credential("headless").is_none()); // registered but no UI assert!(r.credential("nope").is_none()); // unknown } #[test] fn expired_entries_drop_from_listing_and_credential() { let r = PluginRegistry::new(); r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap()); // Force the lease into the past. { let mut map = r.inner.write().unwrap(); map.get_mut("p").unwrap().expires_at = Instant::now().checked_sub(Duration::from_secs(1)).unwrap(); } assert!(r.credential("p").is_none()); // expired → no credential let (plugins, expired) = r.snapshot(); assert!(plugins.is_empty()); assert_eq!(expired, vec!["p".to_string()]); // reaped + announced once // second snapshot no longer reports it as freshly-expired assert!(r.snapshot().1.is_empty()); } #[test] fn remove_reports_live_only() { let r = PluginRegistry::new(); r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap()); assert!(r.remove("p")); // live → announced assert!(!r.remove("p")); // already gone → silent } }