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:
@@ -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