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
+35
View File
@@ -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/<id>`), 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/<id>/"`, 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