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
+123
View File
@@ -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();
});
});