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:
2026-07-18 01:49:42 +02:00
parent d579cd318e
commit ec84b30eae
19 changed files with 1674 additions and 6 deletions
+95
View File
@@ -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]