diff --git a/crates/punktfunk-host/src/events.rs b/crates/punktfunk-host/src/events.rs index f7c41b0c..fa309451 100644 --- a/crates/punktfunk-host/src/events.rs +++ b/crates/punktfunk-host/src/events.rs @@ -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] diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index d3f87bf8..2f4bc371 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -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>, 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; diff --git a/crates/punktfunk-host/src/mgmt/plugins.rs b/crates/punktfunk-host/src/mgmt/plugins.rs new file mode 100644 index 00000000..46f76756 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/plugins.rs @@ -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:` 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 + } +} diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index f9da6fd4..5a58df16 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -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] diff --git a/sdk/README.md b/sdk/README.md index 574caf85..b89e9b08 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -150,6 +150,41 @@ export default definePlugin({ In v1 a plugin is a script you run (see below); the managed runner package is a later step. +### A plugin UI in the console — `servePluginUi` + +A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the +operator. It serves the UI on a loopback ephemeral port behind a per-boot secret; `servePluginUi` +registers it with the host, and the console reverse-proxies to it and adds a nav entry gated by the +console's own session. Your code implements **zero human auth**. + +```ts +import { definePlugin, servePluginUi } from "@punktfunk/host"; + +export default definePlugin({ + name: "rom-manager", + main: async (pf) => { + const ui = await servePluginUi(pf, { + id: "rom-manager", + title: "ROM Manager", + icon: "gamepad-2", // a lucide icon name + staticDir: new URL("../dist/ui", import.meta.url), // your built SPA + fetch: (req) => appRouter(req), // plugin-local REST/SSE (after a static miss) + }); + try { + await runForever(); + } finally { + await ui.close(); // deregister + stop + } + }, +}); +``` + +Requests reach `fetch` **prefix-stripped** (the console proxy removed `/plugin-ui/`), so your app +sees `/`, `/api/scan`, … — the original prefix is on `X-Forwarded-Prefix`. `servePluginUi` serves +`staticDir` first (with an `index.html` SPA fallback for navigations); return `undefined` from `fetch` +to fall through to it. Build your SPA with a relative base (`base: "./"` + hash routing) or an absolute +`base: "/plugin-ui//"`, and expect a dark canvas. Requires the Bun runtime (the runner is bun). + ## The runner: `punktfunk-scripting` Instead of one unit file per script, run everything under the managed runner — it discovers diff --git a/sdk/examples/plugin-ui.ts b/sdk/examples/plugin-ui.ts new file mode 100644 index 00000000..bceee333 --- /dev/null +++ b/sdk/examples/plugin-ui.ts @@ -0,0 +1,85 @@ +// ── Example · console-hosted plugin UI ──────────────────────────────────────────────────────── +// A plugin that surfaces a UI inside the punktfunk web console (plugin-ui-surface design). It +// serves a page + a live SSE feed on a loopback port behind a per-boot secret; `servePluginUi` +// registers it, and the console proxies to it and adds a "Demo UI" nav entry — no second password, +// no second port for the operator to learn. This also exercises the streaming path end-to-end (the +// U0 spike): the SSE feed must arrive through the console's reverse proxy unbuffered. +// +// Run under the scripting runner, or directly for a quick look: bun examples/plugin-ui.ts +import { + connect, + definePlugin, + type Punktfunk, + servePluginUi, +} from "../src/index.js"; + +// A tiny self-contained page: a heading and a list that prepends one line per SSE tick. The +// EventSource URL is RELATIVE (`./events`) so it resolves under the console proxy prefix. +const PAGE = `Demo UI + +

punktfunk plugin UI demo

Live ticks (proxied SSE):

    +`; + +// The plugin's dynamic handler. Paths arrive prefix-stripped (the console proxy removed +// `/plugin-ui/demo-ui`), so we match `/` and `/events` directly. +const handle = (req: Request): Response | undefined => { + const { pathname } = new URL(req.url); + if (pathname === "/events") { + // A ticking SSE stream — the thing the proxy must forward unbuffered. + let n = 0; + const body = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const timer = setInterval(() => { + controller.enqueue( + enc.encode(`data: tick ${++n} @ ${new Date().toISOString()}\n\n`), + ); + }, 1000); + req.signal.addEventListener("abort", () => { + clearInterval(timer); + controller.close(); + }); + }, + }); + return new Response(body, { + headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }, + }); + } + if (pathname === "/" || pathname === "/index.html") { + return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } }); + } + return undefined; // fall through → 404 +}; + +const plugin = definePlugin({ + name: "demo-ui", + main: async (pf) => { + const ui = await servePluginUi(pf, { + id: "demo-ui", + title: "Demo UI", + icon: "puzzle", + fetch: handle, + }); + console.log(`[demo-ui] serving on ${ui.url} — open the console's "Demo UI" nav entry`); + // Run until asked to stop, then deregister cleanly (abrupt kills fall back to lease expiry). + await new Promise((resolve) => { + process.once("SIGINT", resolve); + process.once("SIGTERM", resolve); + }); + await ui.close(); + }, +}); + +export default plugin; + +// Allow a direct `bun examples/plugin-ui.ts` run outside the managed runner. +if (import.meta.main) { + const pf = await connect(); + await (plugin.main as (pf: Punktfunk) => Promise)(pf); + pf.close(); +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 69e462f5..c3ee4058 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -30,6 +30,11 @@ import { export type { HostApi } from "./api.js"; export { HttpStatusError } from "./core.js"; export type { ConnectOptions } from "./config.js"; +export { + type PluginUiHandle, + type PluginUiOptions, + servePluginUi, +} from "./ui.js"; export type { ClientRef, DeviceRef, diff --git a/sdk/src/ui.ts b/sdk/src/ui.ts new file mode 100644 index 00000000..ac466a72 --- /dev/null +++ b/sdk/src/ui.ts @@ -0,0 +1,206 @@ +// `servePluginUi` (plugin-ui-surface design §4) — the whole plugin side of a console-hosted UI in +// one call. A plugin serves its UI on a **loopback ephemeral port** behind a **per-boot secret**, +// registers `{title, ui:{port, secret, icon}}` with the host, and renews the lease on a timer; the +// web console reverse-proxies to it and grows a nav entry. The plugin author writes zero human auth, +// discovery, or TLS — all of that lives here. +// +// import { definePlugin, servePluginUi } from "@punktfunk/host"; +// +// export default definePlugin({ +// name: "rom-manager", +// main: async (pf) => { +// const ui = await servePluginUi(pf, { +// id: "rom-manager", title: "ROM Manager", icon: "gamepad-2", +// staticDir: new URL("../dist/ui", import.meta.url), // built SPA +// fetch: (req) => appRouter(req), // plugin-local REST/SSE +// }); +// try { await runEngineForever(); } finally { await ui.close(); } +// }, +// }); +// +// Design notes: +// - **Runtime**: Bun (the scripting runner IS bun; a `node:http` lane is deferred — design Q1). +// - **Registration uses `pf.request`, not `pf.api.*`** (design D7): under the packaged runner the +// facade is built by the runner's *bundled* SDK copy, whose generated client may predate the +// `/plugins` endpoints; the untyped request seam has existed since 0.1.0 and is skew-proof. +// - **The host only ever dials 127.0.0.1:** — we register a port, never an address (D5). +import { createHash, timingSafeEqual } from "node:crypto"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Punktfunk } from "./index.js"; + +/** How often the lease is renewed (host TTL is 90 s — two missed ticks of slack). */ +const DEFAULT_RENEW_MS = 30_000; + +export interface PluginUiOptions { + /** + * The plugin's registered id — its `definePlugin` name (`[a-z][a-z0-9-]*`). The console nav + * entry and the proxy path `/plugin-ui//**` key on this. + */ + id: string; + /** Human-readable title for the console nav entry. */ + title: string; + /** Optional plugin version (informational, shown in the console page header). */ + version?: string; + /** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */ + icon?: string; + /** + * Directory of the built SPA. Requests are served from here first (with an `index.html` SPA + * fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem + * path or a `file:` URL (`new URL("../dist/ui", import.meta.url)`). + */ + staticDir?: string | URL; + /** + * The plugin's own dynamic handler (REST, SSE) — tried after a static miss. Paths arrive + * **prefix-stripped** (the console proxy has already removed `/plugin-ui/`), so this sees + * `/`, `/api/scan`, … The original public prefix is on the `X-Forwarded-Prefix` header if you + * need absolute self-URLs. Return `undefined` to fall through to the SPA fallback. + */ + fetch?: (req: Request) => Response | Promise | undefined; + /** Advanced: lease-renewal cadence in ms (default 30 000). Mainly for tests. */ + renewIntervalMs?: number; +} + +export interface PluginUiHandle { + /** The loopback port the UI is bound to. */ + readonly port: number; + /** `http://127.0.0.1:` — the base the console proxy dials. */ + readonly url: string; + /** Deregister and stop the server (best-effort DELETE, then force-close). */ + close(): Promise; +} + +const warn = (m: string) => console.warn(`[punktfunk] servePluginUi: ${m}`); + +/** A fresh per-boot secret: 32 random bytes as base64url (43 chars, `[A-Za-z0-9_-]`). */ +const mintSecret = (): string => { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Buffer.from(bytes).toString("base64url"); +}; + +/** Resolve a request path to an absolute file inside `root`, or `null` if it escapes (traversal). */ +const staticFile = (root: string, pathname: string): string | null => { + let rel: string; + try { + rel = decodeURIComponent(pathname); + } catch { + return null; // malformed %-encoding + } + if (rel.endsWith("/")) rel += "index.html"; + if (!rel.startsWith("/")) rel = `/${rel}`; + const abs = path.resolve(root, `.${rel}`); + const rootAbs = path.resolve(root); + if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null; + return abs; +}; + +/** + * Serve a plugin UI and register it with the host. Returns once the server is listening and the + * first registration attempt has been made (a failed initial register is warned, not thrown — the + * renewal loop keeps trying, so a momentarily-unreachable host doesn't take the plugin down). + */ +export const servePluginUi = async ( + pf: Punktfunk, + opts: PluginUiOptions, +): Promise => { + if (!/^[a-z][a-z0-9-]*$/.test(opts.id)) { + throw new Error( + `servePluginUi: id "${opts.id}" must be kebab-case ([a-z][a-z0-9-]*)`, + ); + } + if (typeof (globalThis as Record).Bun === "undefined") { + throw new Error( + "servePluginUi requires the Bun runtime (the scripting runner is bun); a Node lane is not yet available", + ); + } + + const root = opts.staticDir + ? typeof opts.staticDir === "string" + ? opts.staticDir + : fileURLToPath(opts.staticDir) + : undefined; + + // One per-boot secret; the console proxy must present it (as a bearer) on every request. Compared + // constant-time against its SHA-256 (mirrors the host's `token_eq`), so no length/content timing. + const secret = mintSecret(); + const secretHash = createHash("sha256").update(secret).digest(); + const authorized = (req: Request): boolean => { + const header = req.headers.get("authorization"); + const presented = header?.startsWith("Bearer ") ? header.slice(7) : undefined; + if (presented === undefined) return false; + const presentedHash = createHash("sha256").update(presented).digest(); + return timingSafeEqual(presentedHash, secretHash); + }; + + const server = Bun.serve({ + hostname: "127.0.0.1", // loopback only — nothing off-box can reach it + port: 0, // ephemeral: no port to configure or collide + async fetch(req) { + if (!authorized(req)) { + return new Response("unauthorized", { status: 401 }); + } + const pathname = new URL(req.url).pathname; + // Built-in liveness — the console page probes this before mounting the iframe. + if (pathname === "/__health") { + return Response.json({ ok: true, id: opts.id, title: opts.title }); + } + // 1) static asset + if (root) { + const file = staticFile(root, pathname); + if (file) { + const bf = Bun.file(file); + if (await bf.exists()) return new Response(bf); + } + } + // 2) the plugin's dynamic handler + if (opts.fetch) { + const res = await opts.fetch(req); + if (res) return res; + } + // 3) SPA fallback: a navigation that matched no asset gets index.html + if ( + root && + req.method === "GET" && + (req.headers.get("accept") ?? "").includes("text/html") + ) { + const index = Bun.file(path.join(root, "index.html")); + if (await index.exists()) return new Response(index); + } + return new Response("not found", { status: 404 }); + }, + }); + + const port = server.port; + if (port == null) throw new Error("Bun.serve did not report a bound port"); + const url = `http://127.0.0.1:${port}`; + const body = { + title: opts.title, + ...(opts.version !== undefined ? { version: opts.version } : {}), + ui: { + port, + secret, + ...(opts.icon !== undefined ? { icon: opts.icon } : {}), + }, + }; + + const register = () => pf.request("PUT", `/plugins/${opts.id}`, body); + // Best-effort initial register: warn but keep the server up if the host is momentarily away. + await register().catch((e) => warn(`initial registration failed: ${e}`)); + const timer = setInterval(() => { + register().catch((e) => warn(`lease renewal failed: ${e}`)); + }, opts.renewIntervalMs ?? DEFAULT_RENEW_MS); + // Don't let the renewal timer alone keep the process alive — the plugin's main loop owns lifetime. + (timer as { unref?: () => void }).unref?.(); + + return { + port, + url, + async close() { + clearInterval(timer); + // Deregister promptly so the nav entry drops without waiting for the lease to expire. + await pf.request("DELETE", `/plugins/${opts.id}`).catch(() => {}); + server.stop(true); // force-close (SSE/long-poll connections included) + }, + }; +}; diff --git a/sdk/test/ui.test.ts b/sdk/test/ui.test.ts new file mode 100644 index 00000000..ed4c6f3f --- /dev/null +++ b/sdk/test/ui.test.ts @@ -0,0 +1,123 @@ +// `servePluginUi` end to end: it registers with the host (secret and all), serves static + dynamic +// + SPA-fallback behind the per-boot secret, renews the lease, and deregisters on close. The mock +// host records what the helper PUTs/DELETEs so we can assert the registration shape. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { connect } from "../src/index.js"; +import { servePluginUi } from "../src/ui.js"; + +const TOKEN = "test-token"; + +interface Registration { + id: string; + body: { title: string; version?: string; ui?: { port: number; secret: string; icon?: string } }; +} + +// A mock management host that captures plugin registry writes. +const registrations: Registration[] = []; +const deletes: string[] = []; + +const host = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) { + return Response.json({ error: "invalid credentials" }, { status: 401 }); + } + if (url.pathname === "/api/v1/host") return Response.json({ hostname: "mock" }); + const m = url.pathname.match(/^\/api\/v1\/plugins\/([a-z][a-z0-9-]*)$/); + if (m) { + if (req.method === "PUT") { + registrations.push({ id: m[1], body: await req.json() }); + return new Response(null, { status: 204 }); + } + if (req.method === "DELETE") { + deletes.push(m[1]); + return new Response(null, { status: 204 }); + } + } + return Response.json({ error: "not found" }, { status: 404 }); + }, +}); + +const hostUrl = `http://127.0.0.1:${host.port}`; + +// A built SPA on disk: an index and one asset. +let staticDir: string; +beforeAll(() => { + staticDir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-ui-")); + fs.writeFileSync(path.join(staticDir, "index.html"), "INDEX"); + fs.writeFileSync(path.join(staticDir, "app.js"), "ASSET"); +}); +afterAll(() => { + host.stop(true); + fs.rmSync(staticDir, { recursive: true, force: true }); +}); + +/** GET the plugin's own server with the captured secret. */ +const authed = (base: string, p: string, secret: string, init?: RequestInit) => + fetch(`${base}${p}`, { ...init, headers: { authorization: `Bearer ${secret}`, ...init?.headers } }); + +describe("servePluginUi", () => { + test("registers, serves, renews, and deregisters", async () => { + const pf = await connect({ url: hostUrl, token: TOKEN }); + const ui = await servePluginUi(pf, { + id: "demo", + title: "Demo", + version: "9.9.9", + icon: "puzzle", + staticDir, + fetch: (req) => + new URL(req.url).pathname === "/api/ping" + ? Response.json({ pong: true }) + : undefined, + renewIntervalMs: 15, + }); + + // --- registration shape (the secret rides along; the port matches the bound server) --- + const reg = registrations.find((r) => r.id === "demo"); + expect(reg).toBeDefined(); + expect(reg?.body.title).toBe("Demo"); + expect(reg?.body.version).toBe("9.9.9"); + expect(reg?.body.ui?.port).toBe(ui.port); + expect(reg?.body.ui?.icon).toBe("puzzle"); + const secret = reg?.body.ui?.secret ?? ""; + expect(secret).toMatch(/^[A-Za-z0-9_-]{43}$/); // 32 random bytes, base64url + + // --- the secret is mandatory on every request --- + expect((await fetch(`${ui.url}/__health`)).status).toBe(401); + const health = await authed(ui.url, "/__health", secret); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ ok: true, id: "demo", title: "Demo" }); + + // --- static assets, then SPA fallback for a navigation that matched no file --- + expect(await (await authed(ui.url, "/", secret, { headers: { accept: "text/html" } })).text()).toBe("INDEX"); + expect(await (await authed(ui.url, "/app.js", secret)).text()).toBe("ASSET"); + const spa = await authed(ui.url, "/some/client/route", secret, { headers: { accept: "text/html" } }); + expect(await spa.text()).toBe("INDEX"); // SPA fallback + // A missing NON-navigation asset is a real 404, not the index. + expect((await authed(ui.url, "/missing.js", secret)).status).toBe(404); + + // --- the plugin's dynamic handler wins for its own routes --- + expect(await (await authed(ui.url, "/api/ping", secret)).json()).toEqual({ pong: true }); + + // --- lease renewal keeps PUTting on the interval --- + const before = registrations.filter((r) => r.id === "demo").length; + await new Promise((r) => setTimeout(r, 60)); + expect(registrations.filter((r) => r.id === "demo").length).toBeGreaterThan(before); + + // --- close deregisters and stops the server --- + await ui.close(); + expect(deletes).toContain("demo"); + await expect(fetch(`${ui.url}/__health`)).rejects.toThrow(); // connection refused + pf.close(); + }); + + test("rejects a non-kebab id before binding anything", async () => { + const pf = await connect({ url: hostUrl, token: TOKEN }); + await expect(servePluginUi(pf, { id: "Bad_Id", title: "x" })).rejects.toThrow(/kebab/); + pf.close(); + }); +}); diff --git a/web/messages/de.json b/web/messages/de.json index 0f3000b5..42413959 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -9,6 +9,11 @@ "nav_clients": "Gekoppelte Geräte", "nav_pairing": "Kopplung", "nav_library": "Bibliothek", + "nav_plugins": "Plugins", + "plugin_offline_title": "Dieses Plugin läuft nicht", + "plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.", + "plugin_retry": "Erneut versuchen", + "plugin_open_new_tab": "In neuem Tab öffnen", "nav_settings": "Einstellungen", "nav_more": "Mehr", "status_title": "Live-Status", diff --git a/web/messages/en.json b/web/messages/en.json index 45c8e2d3..bbfe68cc 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -11,6 +11,11 @@ "nav_library": "Library", "nav_settings": "Settings", "nav_more": "More", + "nav_plugins": "Plugins", + "plugin_offline_title": "This plugin isn't running", + "plugin_offline_hint": "Start the scripting runner, then retry.", + "plugin_retry": "Retry", + "plugin_open_new_tab": "Open in new tab", "status_title": "Live status", "status_video": "Video", "status_audio": "Audio", diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts index e4b5aed4..70f67fd0 100644 --- a/web/server/routes/api/[...].ts +++ b/web/server/routes/api/[...].ts @@ -14,6 +14,16 @@ import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth"; export default defineEventHandler((event) => { const { pathname, search } = getRequestURL(event); + // A plugin UI's proxy credential (its per-boot secret) is fetched server-side by the + // /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a + // session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at + // /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked. + if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) { + setResponseStatus(event, 403); + return { + error: "plugin UI credentials are not accessible from the browser", + }; + } const base = mgmtUrl(); const target = `${base}${pathname}${search}`; const token = mgmtToken(); diff --git a/web/server/routes/plugin-ui/[...].ts b/web/server/routes/plugin-ui/[...].ts new file mode 100644 index 00000000..88fdc2ed --- /dev/null +++ b/web/server/routes/plugin-ui/[...].ts @@ -0,0 +1,78 @@ +// /plugin-ui//** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get +// here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the +// logged-in operator, on the console's own origin, with no separate password. We look up the +// plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie, +// and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1. +// +// This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts +// handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy). +import { + defineEventHandler, + getProxyRequestHeaders, + getRequestURL, + readRawBody, + sendWebResponse, + setResponseStatus, +} from "h3"; +import { + bustCredential, + fetchUiCredential, + PLUGIN_ID_RE, +} from "../../util/pluginProxy"; + +export default defineEventHandler(async (event) => { + const { pathname, search } = getRequestURL(event); + // /plugin-ui// + const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/); + const id = m?.[1]; + if (!id || !PLUGIN_ID_RE.test(id)) { + setResponseStatus(event, 404); + return { error: "not a valid plugin-ui path" }; + } + const rest = m?.[2] ?? "/"; + const prefix = `/plugin-ui/${id}`; + + // Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the + // session cookie so plugin code never sees it. + const headers = getProxyRequestHeaders(event) as Record; + delete headers.cookie; + delete headers.authorization; + headers["x-forwarded-prefix"] = prefix; + const method = event.method; + const body = + method === "GET" || method === "HEAD" + ? undefined + : ((await readRawBody(event, false)) as Uint8Array | undefined); + + // One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died). + const attempt = async (bustCache: boolean): Promise => { + const cred = await fetchUiCredential(id, { bustCache }); + if (!cred) return null; + const target = `http://127.0.0.1:${cred.port}${rest}${search}`; + try { + return await fetch(target, { + method, + headers: { ...headers, authorization: `Bearer ${cred.secret}` }, + body: body as BodyInit | undefined, + redirect: "manual", + }); + } catch { + // The port is dead (plugin crashed/restarted on a new port): drop the stale credential so + // the next request re-resolves it. + bustCredential(id); + return null; + } + }; + + let resp = await attempt(false); + // Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once. + if (resp?.status === 401) { + const retry = await attempt(true); + if (retry) resp = retry; + } + if (!resp) { + setResponseStatus(event, 502); + return { error: `plugin "${id}" is not running` }; + } + return sendWebResponse(event, resp); +}); diff --git a/web/server/util/pluginProxy.ts b/web/server/util/pluginProxy.ts new file mode 100644 index 00000000..9ec007b4 --- /dev/null +++ b/web/server/util/pluginProxy.ts @@ -0,0 +1,74 @@ +// Server-side helper for the plugin-UI reverse proxy (plugin-ui-surface §5). The console proxies +// `/plugin-ui//**` to a plugin's loopback UI server, injecting the plugin's per-boot secret — +// which it fetches here, from the management API, **server-side only** (the secret never reaches the +// browser; the BFF additionally denylists the credential endpoint from the generic passthrough). +// +// The credential is cached briefly so a burst of iframe asset requests doesn't hammer the host. On a +// 401 from the plugin (its secret rotated on restart within the cache window) the proxy busts this +// cache and re-fetches once — see the route. +import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth"; + +/** A plugin id — its `definePlugin` name; the same shape the host validates. */ +export const PLUGIN_ID_RE = /^[a-z][a-z0-9-]*$/; + +/** The proxy credential for a plugin's loopback UI. */ +export interface UiCredential { + port: number; + secret: string; +} + +const TTL_MS = 15_000; +const cache = new Map(); + +/** Drop a cached credential (called when a plugin's secret proved stale). */ +export function bustCredential(id: string): void { + cache.delete(id); +} + +/** + * Fetch `{port, secret}` for a plugin's UI from the management API (bearer, loopback). Returns + * `null` when the plugin isn't registered / has no UI (a 404). Results are cached for {@link TTL_MS}; + * pass `bustCache` to force a fresh read (the stale-secret retry). Throws only on a missing mgmt + * token (a deploy misconfig) — a transient upstream error resolves to `null` (treated as offline) + * and is not cached. + */ +export async function fetchUiCredential( + id: string, + opts?: { bustCache?: boolean }, +): Promise { + const now = Date.now(); + if (!opts?.bustCache) { + const hit = cache.get(id); + if (hit && now - hit.at < TTL_MS) return hit.cred; + } + + const base = mgmtUrl(); + const token = mgmtToken(); + if (!token) { + throw new Error( + "management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)", + ); + } + // The host serves the credential over HTTPS with its self-signed loopback cert; relax + // verification for that one loopback hop only (the same scoping the /api BFF uses). + const fetchOptions = isLoopbackUrl(base) + ? ({ tls: { rejectUnauthorized: false } } as unknown as RequestInit) + : undefined; + const resp = await fetch(`${base}/api/v1/plugins/${id}/ui-credential`, { + ...fetchOptions, + headers: { authorization: `Bearer ${token}` }, + }); + + if (resp.ok) { + const cred = (await resp.json()) as UiCredential; + cache.set(id, { cred, at: now }); + return cred; + } + if (resp.status === 404) { + // Definitively not running / no UI — cache the negative so a dead iframe doesn't spin. + cache.set(id, { cred: null, at: now }); + return null; + } + // Transient (401/5xx): don't cache, let the next request retry. + return null; +} diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts new file mode 100644 index 00000000..abb911ea --- /dev/null +++ b/web/src/api/plugins.ts @@ -0,0 +1,65 @@ +// The plugin directory the console reads to grow its nav (plugin-ui-surface §5). This is a +// hand-written client (not orval-generated) so the nav works without regenerating the API client +// for the new endpoints; it rides the same `/api` BFF path as every other call, so the bearer token +// is injected server-side and the browser only ever sends its session cookie. +import { useQuery } from "@tanstack/react-query"; +import { + Blocks, + Boxes, + Clapperboard, + Database, + FolderCog, + Gamepad2, + Home, + type LucideIcon, + Plug, + Puzzle, + Wrench, +} from "lucide-react"; +import { apiFetch } from "@/api/fetcher"; + +export interface PluginUiSummary { + port: number; + icon?: string; +} + +export interface PluginSummary { + id: string; + title: string; + version?: string; + /** Present iff the plugin serves a UI (and thus gets a nav entry). */ + ui?: PluginUiSummary; +} + +// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat +// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle. +const ICONS: Record = { + "gamepad-2": Gamepad2, + puzzle: Puzzle, + wrench: Wrench, + database: Database, + home: Home, + blocks: Blocks, + boxes: Boxes, + plug: Plug, + "folder-cog": FolderCog, + clapperboard: Clapperboard, +}; + +/** Resolve a registered icon name to a component (Puzzle fallback). */ +export const pluginIcon = (name?: string): LucideIcon => + (name ? ICONS[name] : undefined) ?? Puzzle; + +/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */ +export function usePlugins() { + return useQuery({ + queryKey: ["plugins"], + queryFn: () => apiFetch("/api/v1/plugins"), + refetchInterval: 30_000, + refetchOnWindowFocus: true, + }); +} + +/** Only the plugins that surface a UI — the ones that get a nav entry. */ +export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] => + (list ?? []).filter((p) => p.ui); diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index e3238500..c748ba01 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { motion, stagger } from "motion/react"; import { type ReactNode, useState } from "react"; +import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins"; import { BrandMark } from "@/components/brand-mark"; import { Wordmark } from "@/components/wordmark"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; @@ -91,6 +92,7 @@ export function AppShell({ children }: { children: ReactNode }) { ))} +
    @@ -119,14 +121,54 @@ export function AppShell({ children }: { children: ReactNode }) { ); } +/** Desktop sidebar: the dynamic "Plugins" group, fed by the plugin directory. Renders nothing until + * at least one plugin surfaces a UI — a host with no plugins sees zero extra chrome. */ +function PluginNavSection() { + const { data } = usePlugins(); + const plugins = uiPlugins(data); + if (plugins.length === 0) return null; + return ( +
    +

    + {m.nav_plugins()} +

    + {plugins.map((p) => { + const Icon = pluginIcon(p.ui?.icon); + return ( + + + + {p.title} + + ); + })} +
    + ); +} + /** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */ function MobileNav() { const [moreOpen, setMoreOpen] = useState(false); const pathname = useRouterState({ select: (s) => s.location.pathname }); - // Highlight "More" when the current route lives in the overflow. - const overflowActive = MOBILE_OVERFLOW.some( - (n) => pathname === n.to || pathname.startsWith(`${n.to}/`), - ); + const { data } = usePlugins(); + const plugins = uiPlugins(data); + // Highlight "More" when the current route lives in the overflow — plugins included. + const overflowActive = + pathname.startsWith("/plugins/") || + MOBILE_OVERFLOW.some( + (n) => pathname === n.to || pathname.startsWith(`${n.to}/`), + ); // Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon // at the same height. const tab = @@ -164,6 +206,22 @@ function MobileNav() { {label()} ))} + {plugins.map((p) => { + const Icon = pluginIcon(p.ui?.icon); + return ( + setMoreOpen(false)} + className={cn(tab, "rounded-md")} + activeProps={{ className: "text-[var(--brand-light)]" }} + > + + {p.title} + + ); + })} )} diff --git a/web/src/routes/plugins.$pluginId.$.tsx b/web/src/routes/plugins.$pluginId.$.tsx new file mode 100644 index 00000000..1579d7e8 --- /dev/null +++ b/web/src/routes/plugins.$pluginId.$.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SectionPlugin } from "@/sections/Plugins"; + +// A plugin's console-hosted UI (plugin-ui-surface). The `$` splat carries the plugin's own path so +// deep links survive a reload; the section maps it to the iframe src and keeps it in sync. +export const Route = createFileRoute("/plugins/$pluginId/$")({ + component: SectionPlugin, +}); diff --git a/web/src/sections/Plugins/index.tsx b/web/src/sections/Plugins/index.tsx new file mode 100644 index 00000000..6b5fb00d --- /dev/null +++ b/web/src/sections/Plugins/index.tsx @@ -0,0 +1,136 @@ +// A plugin's UI, embedded in the console (plugin-ui-surface §5). We probe the plugin's liveness +// first and only mount the iframe when it answers — otherwise the iframe would show the proxy's raw +// 502. The iframe is same-origin (proxied through /plugin-ui), so the plugin can talk to its own +// loopback REST with the operator's session and, optionally, keep the address bar in sync by posting +// `{ type: "pf-ui:navigate", path }` to the parent. +import { useQuery } from "@tanstack/react-query"; +import { getRouteApi, useNavigate } from "@tanstack/react-router"; +import { ExternalLink, RefreshCw } from "lucide-react"; +import { type FC, useEffect, useMemo, useRef } from "react"; +import { pluginIcon, usePlugins } from "@/api/plugins"; +import { Button } from "@/components/ui/button"; +import { useLocale } from "@/lib/i18n"; +import { m } from "@/paraglide/messages"; + +const route = getRouteApi("/plugins/$pluginId/$"); + +export const SectionPlugin: FC = () => { + useLocale(); + const { pluginId, _splat } = route.useParams(); + const navigate = useNavigate(); + const iframeRef = useRef(null); + + // Header metadata (title/version/icon) from the directory; falls back to the id. + const { data: plugins } = usePlugins(); + const meta = plugins?.find((p) => p.id === pluginId); + const Icon = pluginIcon(meta?.ui?.icon); + const title = meta?.title ?? pluginId; + + // Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the + // offline card (the manual Retry re-probes). + const health = useQuery({ + queryKey: ["plugin-health", pluginId], + queryFn: async () => { + const r = await fetch(`/plugin-ui/${pluginId}/__health`, { + credentials: "same-origin", + }); + if (!r.ok) throw new Error(`health ${r.status}`); + return true; + }, + retry: false, + refetchInterval: (q) => (q.state.status === "error" ? false : 20_000), + }); + + // The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives + // the console URL via postMessage (below), never the src — so there's no reload loop. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path + const initialSrc = useMemo( + () => `/plugin-ui/${pluginId}/${_splat ?? ""}`, + [pluginId], + ); + + // Keep the console address bar in sync with the plugin's internal routing. + useEffect(() => { + const onMessage = (e: MessageEvent) => { + if (e.source !== iframeRef.current?.contentWindow) return; + const data = e.data as { type?: string; path?: string }; + if (data?.type === "pf-ui:navigate" && typeof data.path === "string") { + navigate({ + to: "/plugins/$pluginId/$", + params: { pluginId, _splat: data.path.replace(/^\//, "") }, + replace: true, + }); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [pluginId, navigate]); + + return ( +
    + {/* Header strip: identity + open-in-new-tab (the plugin stands alone full-window too). */} +
    + +

    {title}

    + {meta?.version && ( + + v{meta.version} + + )} + + + {m.plugin_open_new_tab()} + +
    + + {health.isError ? ( + health.refetch()} /> + ) : health.isSuccess ? ( +