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:
@@ -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
|
||||
|
||||
@@ -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 = `<!doctype html><meta charset=utf-8><title>Demo UI</title>
|
||||
<style>body{font:15px system-ui;margin:2rem;color:#e5e7eb;background:#0b0b0f}li{opacity:.9}</style>
|
||||
<h1>punktfunk plugin UI demo</h1><p>Live ticks (proxied SSE):</p><ul id=log></ul>
|
||||
<script>
|
||||
const log = document.getElementById("log");
|
||||
new EventSource("./events").onmessage = (e) => {
|
||||
const li = document.createElement("li"); li.textContent = e.data; log.prepend(li);
|
||||
};
|
||||
</script>`;
|
||||
|
||||
// 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<void>((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<void>)(pf);
|
||||
pf.close();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+206
@@ -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:<port>** — 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/<id>/**` 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/<id>`), 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<Response | undefined> | 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:<port>` — the base the console proxy dials. */
|
||||
readonly url: string;
|
||||
/** Deregister and stop the server (best-effort DELETE, then force-close). */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
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<PluginUiHandle> => {
|
||||
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<string, unknown>).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)
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user