feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):
- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
/api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
the mTLS read-only allowlist); plugins.changed event; port-only registration
(proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.
OpenAPI regen for the new endpoints follows in the next commit (built on Linux).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,12 @@ pub enum EventKind {
|
||||
/// API (RFC §8) lands.
|
||||
source: String,
|
||||
},
|
||||
#[serde(rename = "plugins.changed")]
|
||||
PluginsChanged {
|
||||
/// The plugin whose registration changed (registered, restarted, deregistered, or
|
||||
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
|
||||
id: String,
|
||||
},
|
||||
#[serde(rename = "host.started")]
|
||||
HostStarted {
|
||||
version: String,
|
||||
@@ -190,6 +196,7 @@ impl EventKind {
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
EventKind::HostStopping => "host.stopping",
|
||||
}
|
||||
@@ -495,6 +502,19 @@ mod tests {
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
|
||||
);
|
||||
|
||||
let ev = HostEvent {
|
||||
seq: 3,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::PluginsChanged {
|
||||
id: "rom-manager".into(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,6 +38,7 @@ mod hooks;
|
||||
mod host;
|
||||
mod library;
|
||||
mod native;
|
||||
mod plugins;
|
||||
mod session;
|
||||
mod shared;
|
||||
mod stats;
|
||||
@@ -223,7 +224,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
))
|
||||
.routes(routes!(stats::logs_get))
|
||||
.routes(routes!(events::stream_events))
|
||||
.routes(routes!(hooks::get_hooks, hooks::set_hooks)),
|
||||
.routes(routes!(hooks::get_hooks, hooks::set_hooks))
|
||||
.routes(routes!(plugins::list_plugins))
|
||||
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
|
||||
.routes(routes!(plugins::get_ui_credential)),
|
||||
)
|
||||
.split_for_parts()
|
||||
}
|
||||
@@ -261,6 +265,7 @@ pub fn openapi_json() -> String {
|
||||
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
||||
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
|
||||
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
||||
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
//! 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:<port>` 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:<port>`; 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<PluginUi>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<PluginUiPublic>,
|
||||
}
|
||||
|
||||
/// `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<String>,
|
||||
}
|
||||
|
||||
/// One live registration. `expires_at` is a **monotonic** [`Instant`] (immune to wall-clock jumps).
|
||||
struct Stored {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
ui: Option<StoredUi>,
|
||||
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<String>, ui: &Option<StoredUi>) -> bool {
|
||||
self.title == title && self.version == *version && self.ui == *ui
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide plugin registry.
|
||||
pub(crate) struct PluginRegistry {
|
||||
inner: RwLock<HashMap<String, Stored>>,
|
||||
}
|
||||
|
||||
/// A validated registration ready to store (title trimmed, ui fields checked).
|
||||
struct Valid {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
ui: Option<StoredUi>,
|
||||
}
|
||||
|
||||
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<PluginSummary>, Vec<String>) {
|
||||
let now = Instant::now();
|
||||
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut expired: Vec<String> = 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<PluginSummary> = 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<UiCredential> {
|
||||
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<PluginRegistry> = 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::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
|
||||
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
|
||||
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<StoredUi, String> {
|
||||
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<String>,
|
||||
ApiJson(reg): ApiJson<PluginRegistration>,
|
||||
) -> 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<Vec<PluginSummary>> {
|
||||
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<String>) -> 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<String>) -> 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<_>>(),
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,18 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
||||
"the client roster {p} must require the bearer token, not just a paired cert"
|
||||
);
|
||||
}
|
||||
// The plugin directory is admin-only — a paired streaming cert has no business enumerating the
|
||||
// host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3).
|
||||
for p in [
|
||||
"/api/v1/plugins",
|
||||
"/api/v1/plugins/rom-manager/ui-credential",
|
||||
] {
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req(p), fp).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the plugin directory {p} must require the bearer token, not just a paired cert"
|
||||
);
|
||||
}
|
||||
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
||||
@@ -574,6 +586,89 @@ async fn idr_requires_an_active_stream() {
|
||||
assert!(state.force_idr.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// The plugin registry round-trips through the router: register → list (secret-free) → credential
|
||||
/// (secret present) → deregister. Guards the wiring, auth, and — the security-critical bit — that
|
||||
/// the UI secret never appears in the browser-visible listing (plugin-ui-surface §7, D6).
|
||||
#[tokio::test]
|
||||
async fn plugin_registry_roundtrip() {
|
||||
let app = test_app(test_state(), None);
|
||||
let id = "test-plugin-roundtrip";
|
||||
let secret = "s3cr3t-abcdefghijkl"; // 19 chars, valid [A-Za-z0-9_-]
|
||||
|
||||
// Register with a UI surface → 204.
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
put_json(
|
||||
&format!("/api/v1/plugins/{id}"),
|
||||
serde_json::json!({
|
||||
"title": "Test Plugin",
|
||||
"ui": { "port": 49321, "secret": secret, "icon": "gamepad-2" }
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
// It lists — and the secret appears NOWHERE in the listing body.
|
||||
let (status, body) = send(&app, get_req("/api/v1/plugins")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let mine = body
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["id"] == id)
|
||||
.expect("registered plugin is listed");
|
||||
assert_eq!(mine["title"], "Test Plugin");
|
||||
assert_eq!(mine["ui"]["port"], 49321);
|
||||
assert_eq!(mine["ui"]["icon"], "gamepad-2");
|
||||
assert!(
|
||||
!body.to_string().contains(secret),
|
||||
"the listing must never carry the UI secret"
|
||||
);
|
||||
|
||||
// The credential endpoint (server-side proxy lookup) DOES carry it.
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["secret"], secret);
|
||||
assert_eq!(body["port"], 49321);
|
||||
|
||||
// Deregister → gone from the listing, credential 404s.
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
axum::http::Request::delete(format!("/api/v1/plugins/{id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
let (_, body) = send(&app, get_req("/api/v1/plugins")).await;
|
||||
assert!(
|
||||
body.as_array().unwrap().iter().all(|p| p["id"] != id),
|
||||
"deregistered plugin must not list"
|
||||
);
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
// A structurally invalid registration is a 400 (privileged port).
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
put_json(
|
||||
&format!("/api/v1/plugins/{id}"),
|
||||
serde_json::json!({ "title": "x", "ui": { "port": 80, "secret": secret } }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// The OpenAPI document lists every route with a unique operationId (codegen relies
|
||||
/// on both), and the checked-in copy is current.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user