feat: collapse plugin runtime to one tier behind a plugin SDK

This commit is contained in:
LukeGus committed 2026-09-22 14:11:50 -05:00
1 parent 6e058f6e9e
commit 4758016f41
96 files changed
+34631 -8551

No files matched your search

@@ -1,20 +1,19 @@
/**
* database.ts mounts /plugin-api behind authenticateJWT, the same way every
* other router is gated. This exercises that exact wiring (rather than
* http-bridge.test.ts's bare app, which mounts the dispatcher with no auth
* in front of it on purpose, to test the dispatcher in isolation) so a
* regression here - an unauthenticated caller reaching a plugin route - is
* caught.
* /plugin-api is mounted behind authenticateJWT in database.ts, the same way
* every other router is gated, so an unauthenticated caller can never reach
* plugin code.
*
* The dispatcher itself has no routes registered in A1: the worker HTTP
* bridge was removed with the worker tier and A4 rebuilds routing on top of
* the SDK. What still has to hold, and what this covers, is that the auth
* gate sits in front of the mount and that an unknown plugin id is a 404
* rather than a crash.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import {
createFixturePlugin,
type Fixture,
} from "../../plugins/fixture-plugin.js";
const state = vi.hoisted(() => ({
validToken: "valid-token",
@@ -22,13 +21,6 @@ const state = vi.hoisted(() => ({
}));
vi.mock("../../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
@@ -38,133 +30,108 @@ vi.mock("../../../utils/logger.js", () => ({
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown> & { headers: Record<string, string> },
res: { status: (code: number) => { json: (body: unknown) => void } },
next: () => void,
) => {
const auth = req.headers["authorization"];
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
if (token !== state.validToken) {
res.status(401).json({ error: "Missing authentication token" });
return;
}
req.userId = state.userId;
next();
},
}),
},
}));
const { AuthManager } = await import("../../../utils/auth-manager.js");
const { PluginBroker } = await import("../../../plugins/broker.js");
const { PluginLoader } = await import("../../../plugins/loader.js");
const { buildPluginRouter } = await import("../../../plugins/http-bridge.js");
const pluginApi = await import("../../../database/routes/plugin-api-routes.js");
const WORKER_TIMEOUT = 30_000;
/** Stands in for AuthManager's middleware, with the same 401 behaviour. */
function authenticateJWT(
req: express.Request,
res: express.Response,
next: express.NextFunction,
): void {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
describe("/plugin-api behind the app's real auth middleware", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
let server: Server | null = null;
let baseUrl = "";
if (token !== state.validToken) {
res.status(401).json({ error: "Unauthorized" });
return;
}
beforeEach(async () => {
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
(req as express.Request & { userId?: string }).userId = state.userId;
next();
}
const app = express();
// Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes).
app.use("/plugin-api", authenticateJWT, pluginApi.default);
let server: Server | null = null;
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
async function startServer(): Promise<string> {
const app = express();
// The same order database.ts uses: the gate, then the dispatcher.
app.use("/plugin-api", authenticateJWT, pluginApi.default);
return new Promise((resolve) => {
server = app.listen(0, "127.0.0.1", () => {
const { port } = server!.address() as AddressInfo;
resolve(`http://127.0.0.1:${port}`);
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/whoami", async (req) => ({
userId: req.userId ?? null,
}));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
});
}
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
await new Promise<void>((resolve) => server?.close(() => resolve()));
afterEach(async () => {
if (server) {
await new Promise((resolve) => server!.close(() => resolve(undefined)));
server = null;
while (fixtures.length) fixtures.pop()!.cleanup();
}
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
});
describe("/plugin-api authentication", () => {
it("rejects a request with no token", async () => {
const base = await startServer();
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`);
expect(response.status).toBe(401);
});
it(
"rejects a request with no token before it reaches the plugin",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`);
expect(res.status).toBe(401);
},
WORKER_TIMEOUT,
);
it("rejects a request with the wrong token", async () => {
const base = await startServer();
it(
"rejects a request with an invalid token",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
headers: { authorization: "Bearer not-the-right-token" },
});
expect(res.status).toBe(401);
},
WORKER_TIMEOUT,
);
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: "Bearer nope" },
});
it(
"forwards the verified userId once authenticated",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
headers: { authorization: `Bearer ${state.validToken}` },
expect(response.status).toBe(401);
});
// Past the gate, an id nothing registered is a plain 404. The auth check
// runs first either way, so a caller cannot use the difference between 401
// and 404 to find out which plugins are installed.
it("returns 404 for an unregistered plugin id when authenticated", async () => {
const base = await startServer();
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
expect(response.status).toBe(404);
});
it("dispatches to a registered router only when authenticated", async () => {
const router = express.Router();
router.get("/thing", (req, res) => {
res.json({
ok: true,
userId: (req as express.Request & { userId?: string }).userId,
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ userId: state.userId });
},
WORKER_TIMEOUT,
);
});
pluginApi.registerPluginRouter("sample-plugin", router);
const base = await startServer();
const unauthorized = await fetch(`${base}/plugin-api/sample-plugin/thing`);
expect(unauthorized.status).toBe(401);
const authorized = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
expect(authorized.status).toBe(200);
// The user id the plugin sees is the one the gate verified, never one the
// caller supplied.
expect(await authorized.json()).toEqual({
ok: true,
userId: state.userId,
});
});
});
@@ -17,12 +17,18 @@ interface PluginRow {
tier: string;
source: string;
state: string;
lastError?: string | null;
manifestJson: string;
}
const state = vi.hoisted(() => ({
plugins: new Map<string, PluginRow>(),
grants: [] as { pluginId: string; capability: string; grantedBy: string }[],
grants: [] as {
pluginId: string;
capability: string;
grantedBy: string | null;
source?: string;
}[],
// Which users have admin.plugins.manage, keyed by userId.
managers: new Set<string>(["admin-1"]),
}));
@@ -62,6 +68,9 @@ vi.mock("../../../utils/auth-manager.js", () => ({
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
// The list route asks directly rather than gating the whole mount, so
// it can return a narrower shape to a non-admin instead of a 403.
hasPermission: async (userId: string) => state.managers.has(userId),
requirePermission:
(_permission: string) =>
(
@@ -113,7 +122,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
grant: async (input: {
pluginId: string;
capability: string;
grantedBy: string;
grantedBy: string | null;
source?: string;
}) => {
state.grants.push(input);
return input;
@@ -140,7 +150,7 @@ function makePlugin(overrides: Partial<PluginRow> = {}): PluginRow {
source: "community",
state: "enabled",
manifestJson: JSON.stringify({
permissions: ["hosts.read", "storage.own"],
capabilities: ["hosts:read", "kv:own"],
}),
...overrides,
};
@@ -170,11 +180,11 @@ describe("plugins route", () => {
server = null;
});
it("lists a plugin's declared permissions and granted capabilities", async () => {
it("lists a plugin's declared and granted capabilities", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
@@ -184,8 +194,8 @@ describe("plugins route", () => {
expect(body).toEqual([
expect.objectContaining({
id: "sample-plugin",
permissions: ["hosts.read", "storage.own"],
grantedCapabilities: ["hosts.read"],
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
}),
]);
});
@@ -196,19 +206,19 @@ describe("plugins route", () => {
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
granted: true,
});
expect(state.grants).toEqual([
{
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
},
]);
@@ -231,7 +241,7 @@ describe("plugins route", () => {
const res = await fetch(`${baseUrl}/plugins/ghost/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(404);
@@ -243,12 +253,12 @@ describe("plugins route", () => {
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(state.grants).toHaveLength(1);
@@ -258,19 +268,19 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
granted: false,
});
expect(state.grants).toEqual([]);
@@ -280,7 +290,7 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
@@ -288,7 +298,7 @@ describe("plugins route", () => {
});
describe("admin.plugins.manage gate", () => {
it("a non-admin authenticated user cannot list plugins", async () => {
it("lets a non-admin list plugins", async () => {
// GET stays open to any authenticated user on purpose: the app shell
// calls it for every session to know which plugin tabs to register.
state.plugins.set("sample-plugin", makePlugin());
@@ -300,6 +310,58 @@ describe("plugins route", () => {
expect(res.status).toBe(200);
});
// What a plugin may do, what it has been granted and why it failed are
// operational details. The shell needs none of them to render a tab.
it("hides capabilities, grants and lastError from a non-admin", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const [plugin] = await res.json();
expect(plugin).toEqual({
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
enabled: true,
state: "enabled",
contributes: null,
});
});
it("shows the full record to a holder of admin.plugins.manage", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "admin-1" },
});
const [plugin] = await res.json();
expect(plugin).toMatchObject({
id: "sample-plugin",
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
lastError: "port already in use",
});
});
it("403s a non-admin enabling or disabling a plugin", async () => {
state.plugins.set("sample-plugin", makePlugin());
@@ -325,7 +387,7 @@ describe("plugins route", () => {
"content-type": "application/json",
"x-test-user-id": "regular-user",
},
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(403);
@@ -336,12 +398,12 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{
method: "DELETE",
headers: { "x-test-user-id": "regular-user" },