feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+6
View File
@@ -30,6 +30,8 @@ import syncRoutes from "./routes/sync.js";
import pluginApiRoutes from "./routes/plugin-api-routes.js";
import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs";
@@ -1779,6 +1781,10 @@ app.use("/secret-sources", secretSourceRoutes);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/plugins", pluginRoutes);
app.use(
"/plugin-assets",
createPluginAssetsRouter((id) => getPluginRuntime().loader.get(id)),
);
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
const frontendDistPaths = [
+76
View File
@@ -13,6 +13,7 @@ import {
createCurrentPluginRepository,
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import {
findField,
@@ -44,6 +45,65 @@ const requireManagePlugins = permissionManager.requirePermission(
"admin.plugins.manage",
);
/**
* @openapi
* /plugins/public:
* get:
* summary: List the plugin frontends anonymous guest pages need
* description: >
* No auth. A shared-session or collab guest link has no session, yet its
* page still draws surfaces a plugin provides (a remote desktop stream).
* Returns only enabled plugins whose manifest sets contributes.guest,
* with the fields the browser loader needs and nothing operational.
* tags:
* - Plugins
* responses:
* 200:
* description: Guest-capable plugins.
*/
router.get("/public", async (_req: Request, res: Response) => {
try {
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
const plugins = records.flatMap((record) => {
if (record.state !== "enabled") return [];
const loaded = loader.get(record.id);
let manifest: {
contributes?: { guest?: boolean };
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
try {
manifest = JSON.parse(record.manifestJson);
} catch {
return [];
}
if (manifest?.contributes?.guest !== true) return [];
return [
{
id: record.id,
name: record.name,
version: record.version,
enabled: true,
state: loaded?.state ?? record.state,
contributes: { guest: true },
dependencies: manifest.dependencies ?? {},
optionalDependencies: manifest.optionalDependencies ?? {},
...describePluginFrontend(loaded),
},
];
});
res.json(plugins);
} catch (error) {
databaseLogger.error(
"Failed to list guest plugins",
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_list_public" },
);
res.status(500).json({ error: "Failed to list plugins" });
}
});
/**
* @openapi
* /plugins:
@@ -61,6 +121,13 @@ const requireManagePlugins = permissionManager.requirePermission(
* do, what has been granted to it and why it failed are operational
* details, so capabilities, grants and lastError are included only for
* holders of admin.plugins.manage.
*
* The frontend fields drive the browser's plugin loader: `frontend`
* says there is a bundle at /plugin-assets/<id>/frontend.js,
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
* it, and `locales` lists the languages it ships ("en" and xx_YY).
* `dependencies` and `optionalDependencies` let the loader activate
* frontends in the same order the server does.
* tags:
* - Plugins
* responses:
@@ -100,17 +167,23 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let contributes: unknown = null;
let capabilities: string[] = [];
let icon: string | undefined;
let dependencies: Record<string, string> = {};
let optionalDependencies: Record<string, string> = {};
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
icon?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
dependencies = manifest?.dependencies ?? {};
optionalDependencies = manifest?.optionalDependencies ?? {};
} catch {
contributes = null;
}
@@ -123,6 +196,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
state: loaded?.state ?? record.state,
contributes,
icon,
dependencies,
optionalDependencies,
...describePluginFrontend(loaded),
};
if (!canManage) return summary;
+218
View File
@@ -0,0 +1,218 @@
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
// the two directories a plugin ships for the browser, so a request can never
// reach the plugin's backend bundle, migrations or anything outside it.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import express, { type Request, type Response } from "express";
import {
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
import type { LoadedPlugin } from "./loader.js";
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest">;
export interface PluginFrontendInfo {
frontend: boolean;
css: boolean;
/** Changes whenever the built bundle does, for cache busting. */
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
}
function frontendEntry(
plugin: AssetPlugin,
): { dir: string; file: string } | null {
const manifest = plugin.manifest as PluginManifest;
const entry = path.resolve(
plugin.dir,
manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
);
if (!isInside(plugin.dir, entry)) return null;
return { dir: path.dirname(entry), file: entry };
}
function localesDir(plugin: AssetPlugin): string | null {
const manifest = plugin.manifest as PluginManifest;
const dir = path.resolve(plugin.dir, manifest.locales ?? DEFAULT_LOCALES_DIR);
return isInside(plugin.dir, dir) ? dir : null;
}
function isInside(root: string, target: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(target));
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function fileExists(file: string): boolean {
try {
return fs.statSync(file).isFile();
} catch {
return false;
}
}
export function describePluginFrontend(
plugin: AssetPlugin | undefined,
): PluginFrontendInfo {
const none: PluginFrontendInfo = {
frontend: false,
css: false,
assetVersion: null,
locales: [],
};
if (!plugin) return none;
const locales: string[] = [];
const dir = localesDir(plugin);
if (dir && fileExists(path.join(dir, "en.json"))) locales.push("en");
if (dir) {
try {
for (const name of fs.readdirSync(path.join(dir, "translated"))) {
if (/^[a-z]{2}_[A-Z]{2}\.json$/.test(name)) {
locales.push(name.slice(0, -".json".length));
}
}
} catch {
// No translations yet.
}
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
const stat = fs.statSync(entry.file);
const assetVersion = crypto
.createHash("sha256")
.update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`)
.digest("hex")
.slice(0, 12);
return {
frontend: true,
css: fileExists(path.join(entry.dir, "frontend.css")),
assetVersion,
locales: locales.sort(),
};
}
/**
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
*
* `locales/...` reads the plugin's locales directory; anything else reads the
* directory the frontend bundle sits in. Only .js, .css, .json and .map are
* served, and never a path that escapes either directory.
*/
export function resolvePluginAsset(
plugin: AssetPlugin,
requestPath: string,
): string | null {
const clean = requestPath.replace(/^\/+/, "");
if (!clean || clean.includes("\0") || clean.includes("\\")) return null;
if (!/\.(js|css|json|map)$/.test(clean)) return null;
let root: string | null;
let rest: string;
if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
root = frontendEntry(plugin)?.dir ?? null;
rest = clean;
// The backend bundle sits beside the frontend one in dist/.
if (/^backend\.js(\.map)?$/.test(rest)) return null;
}
if (!root) return null;
const file = path.resolve(root, rest);
if (!isInside(root, file) || !fileExists(file)) return null;
return file;
}
const CONTENT_TYPES: Record<string, string> = {
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
};
export function createPluginAssetsRouter(
getPlugin: (id: string) => AssetPlugin | undefined,
): express.Router {
const router = express.Router();
/**
* @openapi
* /plugin-assets/{pluginId}/{path}:
* get:
* summary: Serve a plugin's frontend bundle, stylesheet or locale file
* description: >
* Public, like the app's own /assets, because a module import cannot
* send an auth header and plugin frontends are not secret. Serves only
* the directory holding the plugin's frontend bundle and its locales
* directory. A `v` query parameter (the assetVersion from GET
* /plugins) makes the response cacheable for a year.
* tags:
* - Plugins
* parameters:
* - in: path
* name: pluginId
* required: true
* schema:
* type: string
* - in: path
* name: path
* required: true
* schema:
* type: string
* - in: query
* name: v
* schema:
* type: string
* responses:
* 200:
* description: The file.
* 404:
* description: Unknown plugin or file.
*/
router.get(
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
(req: Request, res: Response) => {
const params = req.params as unknown as Record<string, string>;
const pluginId = params[0];
const plugin = getPlugin(pluginId);
const file = plugin ? resolvePluginAsset(plugin, params[1]) : null;
// Loaded as a module from Electron's file:// page and the Vite dev
// server, so any origin may read it. No credentials are involved.
res.setHeader("Access-Control-Allow-Origin", "*");
res.removeHeader("Access-Control-Allow-Credentials");
res.setHeader("X-Content-Type-Options", "nosniff");
if (!file) {
res.status(404).json({ error: "Not found" });
return;
}
res.setHeader(
"Cache-Control",
typeof req.query.v === "string" && req.query.v
? "public, max-age=31536000, immutable"
: "no-cache",
);
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
res.sendFile(file);
},
);
return router;
}
@@ -92,7 +92,9 @@ vi.mock("../../../utils/permission-manager.js", () => ({
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { list: () => [] } }),
getPluginRuntime: () => ({
loader: { list: () => [], get: () => undefined },
}),
activatePlugin: vi.fn(),
deactivatePlugin: vi.fn(),
}));
@@ -180,6 +182,50 @@ describe("plugins route", () => {
server = null;
});
describe("GET /plugins/public", () => {
it("lists only enabled plugins that opt into guest pages", async () => {
state.plugins.set(
"guest-plugin",
makePlugin({
id: "guest-plugin",
manifestJson: JSON.stringify({
capabilities: ["hosts:read"],
contributes: { guest: true },
}),
}),
);
state.plugins.set(
"off-guest",
makePlugin({
id: "off-guest",
state: "disabled",
manifestJson: JSON.stringify({ contributes: { guest: true } }),
}),
);
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/public`);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
"guest-plugin",
]);
expect(body[0].contributes).toEqual({ guest: true });
expect(body[0]).not.toHaveProperty("capabilities");
expect(body[0]).not.toHaveProperty("lastError");
});
it("skips a plugin whose manifest does not parse", async () => {
state.plugins.set(
"broken",
makePlugin({ id: "broken", manifestJson: "{" }),
);
const res = await fetch(`${baseUrl}/plugins/public`);
expect(await res.json()).toEqual([]);
});
});
it("lists a plugin's declared and granted capabilities", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
@@ -335,6 +381,12 @@ describe("plugins route", () => {
enabled: true,
state: "enabled",
contributes: null,
dependencies: {},
optionalDependencies: {},
frontend: false,
css: false,
assetVersion: null,
locales: [],
});
});
+132
View File
@@ -0,0 +1,132 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import express from "express";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createPluginAssetsRouter,
describePluginFrontend,
resolvePluginAsset,
} from "../../plugins/assets.js";
let root = "";
let plugin: { id: string; dir: string; manifest: never };
let server: Server;
let baseUrl = "";
function write(relative: string, content: string) {
const file = path.join(root, "gizmo", relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-"));
write("dist/frontend.js", "export function activate() {}");
write("dist/frontend.css", ".gizmo{}");
write("dist/backend.js", "secret()");
write("dist/notes.txt", "text");
write("locales/en.json", "{}");
write("locales/translated/de_DE.json", "{}");
write("manifest.json", "{}");
fs.writeFileSync(path.join(root, "outside.js"), "nope");
plugin = {
id: "gizmo",
dir: path.join(root, "gizmo"),
manifest: { id: "gizmo", version: "1.0.0" } as never,
};
const app = express();
app.use(
"/plugin-assets",
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
);
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
fs.rmSync(root, { recursive: true, force: true });
});
describe("describePluginFrontend", () => {
it("reports the bundle, stylesheet, version and locales", () => {
const info = describePluginFrontend(plugin);
expect(info.frontend).toBe(true);
expect(info.css).toBe(true);
expect(info.assetVersion).toMatch(/^[0-9a-f]{12}$/);
expect(info.locales).toEqual(["de_DE", "en"]);
});
it("reports nothing for an unknown plugin", () => {
expect(describePluginFrontend(undefined)).toEqual({
frontend: false,
css: false,
assetVersion: null,
locales: [],
});
});
});
describe("resolvePluginAsset", () => {
it("serves the bundle and locale files", () => {
expect(resolvePluginAsset(plugin, "frontend.js")).toBe(
path.join(root, "gizmo", "dist", "frontend.js"),
);
expect(resolvePluginAsset(plugin, "locales/translated/de_DE.json")).toBe(
path.join(root, "gizmo", "locales", "translated", "de_DE.json"),
);
});
it("never serves the backend bundle", () => {
expect(resolvePluginAsset(plugin, "backend.js")).toBeNull();
});
it("refuses paths that escape the plugin's directories", () => {
expect(resolvePluginAsset(plugin, "../manifest.json")).toBeNull();
expect(resolvePluginAsset(plugin, "../../outside.js")).toBeNull();
expect(resolvePluginAsset(plugin, "locales/../dist/backend.js")).toBeNull();
expect(resolvePluginAsset(plugin, "..\\manifest.json")).toBeNull();
});
it("refuses file types it does not serve", () => {
expect(resolvePluginAsset(plugin, "notes.txt")).toBeNull();
});
});
describe("GET /plugin-assets", () => {
it("serves a bundle as a module any origin can load", async () => {
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/javascript");
expect(res.headers.get("access-control-allow-origin")).toBe("*");
expect(res.headers.get("cache-control")).toBe("no-cache");
expect(await res.text()).toContain("activate");
});
it("caches a versioned request for good", async () => {
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js?v=abc`);
expect(res.headers.get("cache-control")).toContain("immutable");
});
it("404s an unknown plugin", async () => {
const res = await fetch(`${baseUrl}/plugin-assets/nobody/frontend.js`);
expect(res.status).toBe(404);
});
it("404s the backend bundle and traversal attempts", async () => {
for (const target of [
"gizmo/backend.js",
"gizmo/%2e%2e/manifest.json",
"gizmo/..%2f..%2foutside.js",
]) {
const res = await fetch(`${baseUrl}/plugin-assets/${target}`);
expect(res.status, target).toBe(404);
}
});
});
+49 -4
View File
@@ -119,11 +119,9 @@ describe("unknown field rejection", () => {
});
it("rejects an unknown field inside contributes", () => {
const errors = validateManifest(
base({ contributes: { dashboardCards: [] } }),
);
const errors = validateManifest(base({ contributes: { widgets: [] } }));
expect(errors.join()).toMatch(/Unknown field "dashboardCards"/);
expect(errors.join()).toMatch(/Unknown field "widgets"/);
});
it("rejects an unknown field inside a tab", () => {
@@ -557,3 +555,50 @@ describe("tabs and host capabilities", () => {
).toMatch(/duplicates/);
});
});
describe("panels, dashboard cards and guest pages", () => {
it("accepts declared panels, cards and the guest flag", () => {
expect(
validateManifest(
base({
contributes: {
panels: [{ id: "sample-panel", titleKey: "nav.panel" }],
dashboardCards: [
{ id: "sample-card", titleKey: "cards.sample", icon: "Box" },
],
guest: true,
},
}),
),
).toEqual([]);
});
it("rejects duplicate view ids and missing title keys", () => {
const errors = validateManifest(
base({
contributes: {
panels: [
{ id: "p", titleKey: "a" },
{ id: "p", titleKey: "b" },
],
dashboardCards: [{ id: "c" }],
},
}),
).join();
expect(errors).toMatch(/panels\[1\]\.id duplicates "p"/);
expect(errors).toMatch(/dashboardCards\[0\]\.titleKey/);
});
it("rejects an unknown field inside a view and a non-boolean guest", () => {
const errors = validateManifest(
base({
contributes: {
panels: [{ id: "p", titleKey: "a", width: 3 }],
guest: "yes",
},
}),
).join();
expect(errors).toMatch(/Unknown field "width"/);
expect(errors).toMatch(/contributes\.guest" must be a boolean/);
});
});