fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
This commit is contained in:
LukeGus committed 2026-09-21 14:32:59 -05:00
1 parent 19b70430a1
commit c58ccbfc3f
16 files changed
+1317 -78

No files matched your search

+1 -1
View File
@@ -1781,7 +1781,7 @@ app.use("/ai", aiRoutes);
app.use("/", alertRulesRoutes);
app.use("/sync", syncRoutes);
app.use("/plugins", pluginRoutes);
app.use("/plugin-api", pluginApiRoutes);
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
+225 -22
View File
@@ -7,20 +7,36 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentPluginRepository } from "../repositories/factory.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
createCurrentPluginPermissionGrantRepository,
createCurrentPluginRepository,
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const permissionManager = PermissionManager.getInstance();
const requireManagePlugins = permissionManager.requirePermission(
"admin.plugins.manage",
);
/**
* @openapi
* /plugins:
* get:
* summary: List installed plugins and their runtime state
* description: Returns every plugin known to the database, merged with the loader's live state so the UI can tell "enabled but crashed" from "disabled".
* description: >
* Returns every plugin known to the database, merged with the loader's
* live state so the UI can tell "enabled but crashed" from "disabled".
* Open to any authenticated user, not just admins: the app shell calls
* this on every session to decide which plugin-contributed tabs and
* rail items to register, so gating it behind admin.plugins.manage
* would break the shell for non-admin users. Only the mutating routes
* below (enable/disable, grant/revoke) require that permission.
* tags:
* - Plugins
* responses:
@@ -32,28 +48,43 @@ router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
const live = new Map(loader.list().map((p) => [p.id, p]));
const grantRepository = createCurrentPluginPermissionGrantRepository();
const plugins = records.map((record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
try {
contributes = JSON.parse(record.manifestJson)?.contributes ?? null;
} catch {
contributes = null;
}
const plugins = await Promise.all(
records.map(async (record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let permissions: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
permissions?: unknown;
};
contributes = manifest?.contributes ?? null;
permissions = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
: [];
} catch {
contributes = null;
}
return {
id: record.id,
name: record.name,
version: record.version,
tier: record.tier,
source: record.source,
enabled: record.state === "enabled",
runtimeState: loaded?.state ?? "stopped",
lastError: loaded?.lastError ?? null,
contributes,
};
});
const grants = await grantRepository.listByPlugin(record.id);
return {
id: record.id,
name: record.name,
version: record.version,
tier: record.tier,
source: record.source,
enabled: record.state === "enabled",
runtimeState: loaded?.state ?? "stopped",
lastError: loaded?.lastError ?? null,
contributes,
permissions,
grantedCapabilities: grants.map((grant) => grant.capability),
};
}),
);
res.json(plugins);
} catch (error) {
@@ -94,12 +125,15 @@ router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
* description: The plugin's new state.
* 400:
* description: Invalid request body.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
*/
router.patch(
"/:id/state",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const pluginId = String(req.params.id);
@@ -148,4 +182,173 @@ router.patch(
},
);
/**
* @openapi
* /plugins/{id}/grants:
* post:
* summary: Grant a plugin one of its manifest-declared capabilities
* description: >
* The grant is install-wide, not per-user: it unlocks the capability for
* the plugin as a whole. What each call then does with the capability
* (e.g. whose hosts ctx.hosts.list() returns) is still scoped to
* whichever user's request triggered it.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* capability:
* type: string
* responses:
* 200:
* description: The capability is now granted.
* 400:
* description: The capability is not declared in the plugin's manifest.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
*/
router.post(
"/:id/grants",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId as string;
const pluginId = String(req.params.id);
const { capability } = req.body ?? {};
if (typeof capability !== "string" || !capability) {
res.status(400).json({ error: "capability is required" });
return;
}
try {
const repository = createCurrentPluginRepository();
const record = await repository.findById(pluginId);
if (!record) {
res.status(404).json({ error: "Plugin not found" });
return;
}
let declared: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
permissions?: unknown;
};
declared = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
: [];
} catch {
declared = [];
}
if (!declared.includes(capability)) {
res.status(400).json({
error: "This capability is not declared in the plugin's manifest",
});
return;
}
const grantRepository = createCurrentPluginPermissionGrantRepository();
const existing = await grantRepository.findGrant(pluginId, capability);
if (!existing) {
await grantRepository.grant({
pluginId,
capability,
grantedBy: userId,
});
invalidatePluginPermissionCache(pluginId);
}
databaseLogger.info(`Granted ${capability} to plugin ${pluginId}`, {
operation: "plugin_grant",
pluginId,
});
res.json({ id: pluginId, capability, granted: true });
} catch (error) {
databaseLogger.error(
`Failed to grant ${capability} to plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_grant" },
);
res.status(500).json({ error: "Failed to grant the capability" });
}
},
);
/**
* @openapi
* /plugins/{id}/grants/{capability}:
* delete:
* summary: Revoke a previously granted plugin capability
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: path
* name: capability
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The capability is no longer granted.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin, or the capability was not granted.
*/
router.delete(
"/:id/grants/:capability",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const capability = String(req.params.capability);
try {
const revoked =
await createCurrentPluginPermissionGrantRepository().revoke(
pluginId,
capability,
);
if (!revoked) {
res.status(404).json({ error: "That capability was not granted" });
return;
}
invalidatePluginPermissionCache(pluginId);
databaseLogger.info(`Revoked ${capability} from plugin ${pluginId}`, {
operation: "plugin_grant_revoke",
pluginId,
});
res.json({ id: pluginId, capability, granted: false });
} catch (error) {
databaseLogger.error(
`Failed to revoke ${capability} from plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_grant_revoke" },
);
res.status(500).json({ error: "Failed to revoke the capability" });
}
},
);
export default router;
+43 -13
View File
@@ -216,7 +216,7 @@ export class PluginBroker {
runtime: PluginRuntime,
request: PluginRequest,
): Promise<void> {
const { method, args } = request;
const { method, args, callerUserId } = request;
let failure: Error | null = null;
try {
@@ -229,7 +229,7 @@ export class PluginBroker {
);
}
const value = await this.invoke(runtime, method, args);
const value = await this.invoke(runtime, method, args, callerUserId);
this.reply(runtime, { id: request.id, ok: true, value });
} catch (error) {
failure = error instanceof Error ? error : new Error(String(error));
@@ -246,7 +246,7 @@ export class PluginBroker {
}
if (AUDITED.has(method)) {
void this.audit(runtime, method, args, failure);
void this.audit(runtime, method, args, failure, callerUserId);
}
}
@@ -263,6 +263,7 @@ export class PluginBroker {
runtime: PluginRuntime,
method: string,
args: unknown[],
callerUserId?: string,
): Promise<unknown> {
switch (method) {
case "log.debug":
@@ -277,10 +278,10 @@ export class PluginBroker {
return null;
case "hosts.list":
return this.handleHostsList(runtime);
return this.handleHostsList(runtime, callerUserId);
case "hosts.get":
return this.handleHostsGet(runtime, Number(args[0]));
return this.handleHostsGet(runtime, Number(args[0]), callerUserId);
case "storage.get":
return this.handleStorageGet(runtime, storageKey(args[0]));
@@ -304,7 +305,7 @@ export class PluginBroker {
return this.handleHttpRoute(runtime, String(args[0]), String(args[1]));
case "ssh.connect":
return this.handleSshConnect(runtime, Number(args[0]));
return this.handleSshConnect(runtime, Number(args[0]), callerUserId);
case "ssh.exec":
return this.handleSshExec(
@@ -349,8 +350,9 @@ export class PluginBroker {
private async handleHostsList(
runtime: PluginRuntime,
callerUserId?: string,
): Promise<PluginHostView[]> {
const userId = this.requireOwner(runtime);
const userId = this.requireActor(runtime, callerUserId);
const hosts = await this.deps.listHosts(userId);
return hosts.map(toPluginHostView);
}
@@ -358,8 +360,9 @@ export class PluginBroker {
private async handleHostsGet(
runtime: PluginRuntime,
hostId: number,
callerUserId?: string,
): Promise<PluginHostView | null> {
const userId = this.requireOwner(runtime);
const userId = this.requireActor(runtime, callerUserId);
if (!Number.isFinite(hostId)) {
throw new PluginFacingError("hosts.get requires a numeric host id");
}
@@ -453,8 +456,9 @@ export class PluginBroker {
private async handleSshConnect(
runtime: PluginRuntime,
hostId: number,
callerUserId?: string,
): Promise<string> {
const userId = this.requireOwner(runtime);
const userId = this.requireActor(runtime, callerUserId);
if (!Number.isFinite(hostId)) {
throw new PluginFacingError("ssh.connect requires a numeric host id");
}
@@ -651,8 +655,30 @@ export class PluginBroker {
}
}
private requireOwner(runtime: PluginRuntime): string {
const userId = runtime.plugin.ownerUserId;
/**
* Resolves the user a privileged ctx call acts as. This is where the two
* identity models that coexist in one plugin meet:
*
* - Inside an HTTP handler, `callerUserId` is set -- the worker attached
* it from the AsyncLocalStorage context it entered around that specific
* ctx.http.route invocation (see worker-bootstrap.ts). The call acts as
* whichever user's request is currently being served, so
* ctx.hosts.list() returns THEIR hosts, not the plugin installer's.
* Grants stay install-wide (an admin unlocks the capability for the
* plugin as a whole); this is only about whose DATA the call touches.
*
* - From a schedule.every timer or an events.on listener, there is no
* inbound request and so no AsyncLocalStorage context to read from --
* `callerUserId` is undefined. These fall back to ownerUserId, the user
* who last enabled the plugin, which is what background work has
* always run as.
*
* A plugin cannot influence which branch it gets: no ctx method takes a
* userId argument, so `callerUserId` only ever reflects a real inbound
* request the server's own auth middleware verified.
*/
private requireActor(runtime: PluginRuntime, callerUserId?: string): string {
const userId = callerUserId ?? runtime.plugin.ownerUserId;
if (!userId) {
throw new PluginFacingError(
`Plugin ${runtime.plugin.id} has no owning user, so it cannot act on user data`,
@@ -666,13 +692,17 @@ export class PluginBroker {
method: string,
args: unknown[],
failure: Error | null,
callerUserId?: string,
): Promise<void> {
const userId = runtime.plugin.ownerUserId;
const userId = callerUserId ?? runtime.plugin.ownerUserId;
if (!userId) return;
// Attribution comes from the broker, never from the plugin: username is
// the plugin id, not the user's, so a plugin action is never mistaken for
// something the person did themselves.
// something the person did themselves. The userId this is logged against
// is whichever identity requireActor would have used for the same call
// (see its comment), so a request-triggered action is attributed to the
// requester, not always to whoever installed the plugin.
await logAudit({
userId,
username: `plugin:${runtime.plugin.id}`,
+2 -2
View File
@@ -39,8 +39,8 @@ export function buildPluginRouter(
query: req.query,
body: req.body ?? null,
headers: pickHeaders(req),
// The acting user, resolved by the server's own auth middleware.
// A plugin cannot set or spoof this.
// The acting user, verified by authenticateJWT ahead of the
// /plugin-api mount in database.ts. A plugin cannot set or spoof this.
userId: (req as Request & { userId?: string }).userId ?? null,
});
+3 -1
View File
@@ -254,7 +254,9 @@ export class PluginLoader {
/**
* `ownerUserId` is optional only for in-process first-party plugins, which
* do not use the gated ctx and so never act as a user. A worker plugin
* without one cannot reach any user data (see broker.requireOwner).
* without one cannot reach any user data outside of a per-request caller
* identity (see broker.requireActor), which only exists inside an HTTP
* handler invocation and is never a substitute for having an owner at all.
*/
async activate(pluginId: string, ownerUserId?: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
+9
View File
@@ -13,6 +13,15 @@ export interface PluginRequest {
/** Dotted ctx path, e.g. "hosts.get" or "storage.set". */
method: string;
args: unknown[];
/**
* The user whose HTTP request is currently executing inside the worker, if
* any. Set by the worker's own call() from its AsyncLocalStorage context,
* never by plugin code -- no ctx method accepts a userId argument, so a
* plugin has no way to set or forge this itself. Absent for a ctx call made
* from a timer or event listener, which has no inbound request to derive an
* actor from; the broker falls back to the plugin's ownerUserId then.
*/
callerUserId?: string;
}
export interface PluginResponseOk {
+30 -2
View File
@@ -7,6 +7,7 @@
* account of what that does and does not guarantee.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { parentPort, workerData } from "node:worker_threads";
import type {
PluginBootstrapData,
@@ -33,11 +34,30 @@ const httpRoutes = new Map<string, (req: unknown) => unknown>();
const eventListeners = new Map<string, Set<(payload: unknown) => void>>();
const timers = new Map<string, () => void>();
/**
* Tracks which user's HTTP request is currently executing, so a ctx call made
* anywhere in that request's call stack -- including deep inside plugin code
* that has no idea this exists -- can be attributed to them without adding a
* userId parameter to every ctx method.
*
* Only entered around an ctx.http.route handler invocation (see handlePush
* below). A ctx call made from a schedule.every timer or an events.on
* listener runs outside any run() call, so getStore() correctly returns
* undefined for those and the broker falls back to the plugin's owner.
*/
const requestActor = new AsyncLocalStorage<{ userId: string }>();
function call(method: string, ...args: unknown[]): Promise<unknown> {
const id = nextRequestId++;
const callerUserId = requestActor.getStore()?.userId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
port!.postMessage({ id, method, args });
port!.postMessage({
id,
method,
args,
...(callerUserId && { callerUserId }),
});
});
}
@@ -113,8 +133,16 @@ async function handlePush(push: PluginPush): Promise<void> {
return;
}
const requestUserId =
(push.payload as { userId?: string | null } | undefined)?.userId ??
undefined;
try {
const value = await handler(push.payload);
const value = await (requestUserId
? requestActor.run({ userId: requestUserId }, () =>
handler(push.payload),
)
: handler(push.payload));
port!.postMessage({ id: push.replyTo, ok: true, value });
} catch (error) {
port!.postMessage({
@@ -0,0 +1,170 @@
/**
* 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.
*/
import { afterEach, beforeEach, 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",
userId: "user-1",
}));
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(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
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;
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 = "";
beforeEach(async () => {
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
const app = express();
// Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes).
app.use("/plugin-api", authenticateJWT, pluginApi.default);
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}`;
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()));
server = null;
while (fixtures.length) fixtures.pop()!.cleanup();
});
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 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,
);
it(
"forwards the verified userId once authenticated",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
headers: { authorization: `Bearer ${state.validToken}` },
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ userId: state.userId });
},
WORKER_TIMEOUT,
);
});
@@ -0,0 +1,355 @@
/**
* The plugin control plane: listing installed plugins (with their declared
* and granted capabilities) and granting/revoking a capability. Enable/disable
* is not re-tested here beyond what already existed; the grant/revoke routes
* are the new surface this file covers.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
interface PluginRow {
id: string;
name: string;
version: string;
tier: string;
source: string;
state: string;
manifestJson: string;
}
const state = vi.hoisted(() => ({
plugins: new Map<string, PluginRow>(),
grants: [] as { pluginId: string; capability: string; grantedBy: string }[],
// Which users have admin.plugins.manage, keyed by userId.
managers: new Set<string>(["admin-1"]),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
/**
* The caller identifies as whichever user the x-test-user-id header names,
* mirroring the real JWT middleware's job of setting req.userId -- these
* tests only need to vary which user is calling, not verify real tokens.
*/
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown> & { headers: Record<string, string> },
_res: unknown,
next: () => void,
) => {
req.userId = req.headers["x-test-user-id"] ?? "admin-1";
next();
},
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
requirePermission:
(_permission: string) =>
(
req: Record<string, unknown>,
res: {
status: (code: number) => { json: (body: unknown) => void };
},
next: () => void,
) => {
const userId = req.userId as string;
if (!state.managers.has(userId)) {
res.status(403).json({ error: "Insufficient permissions" });
return;
}
next();
},
}),
},
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { list: () => [] } }),
activatePlugin: vi.fn(),
deactivatePlugin: vi.fn(),
}));
vi.mock("../../../plugins/permissions.js", () => ({
invalidatePluginPermissionCache: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
listAll: async () => [...state.plugins.values()],
findById: async (id: string) => state.plugins.get(id) ?? null,
update: async (id: string, changes: Partial<PluginRow>) => {
const existing = state.plugins.get(id);
if (existing) state.plugins.set(id, { ...existing, ...changes });
},
}),
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants
.filter((g) => g.pluginId === pluginId)
.map((g) => ({ capability: g.capability })),
findGrant: async (pluginId: string, capability: string) =>
state.grants.find(
(g) => g.pluginId === pluginId && g.capability === capability,
) ?? null,
grant: async (input: {
pluginId: string;
capability: string;
grantedBy: string;
}) => {
state.grants.push(input);
return input;
},
revoke: async (pluginId: string, capability: string) => {
const before = state.grants.length;
state.grants = state.grants.filter(
(g) => !(g.pluginId === pluginId && g.capability === capability),
);
return state.grants.length < before;
},
}),
}));
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
.default;
function makePlugin(overrides: Partial<PluginRow> = {}): PluginRow {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
tier: "community",
source: "community",
state: "enabled",
manifestJson: JSON.stringify({
permissions: ["hosts.read", "storage.own"],
}),
...overrides,
};
}
describe("plugins route", () => {
let server: Server | null = null;
let baseUrl = "";
beforeEach(async () => {
state.plugins = new Map();
state.grants = [];
state.managers = new Set(["admin-1"]);
const app = express();
app.use(express.json());
app.use("/plugins", pluginRoutes);
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}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server?.close(() => resolve()));
server = null;
});
it("lists a plugin's declared permissions and granted capabilities", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`);
const body = await res.json();
expect(body).toEqual([
expect.objectContaining({
id: "sample-plugin",
permissions: ["hosts.read", "storage.own"],
grantedCapabilities: ["hosts.read"],
}),
]);
});
it("grants a declared capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
granted: true,
});
expect(state.grants).toEqual([
{
pluginId: "sample-plugin",
capability: "hosts.read",
grantedBy: "admin-1",
},
]);
});
it("rejects granting a capability the manifest does not declare", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "ssh.exec" }),
});
expect(res.status).toBe(400);
expect(state.grants).toEqual([]);
});
it("404s granting a capability for a plugin that does not exist", async () => {
const res = await fetch(`${baseUrl}/plugins/ghost/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
});
expect(res.status).toBe(404);
});
it("does not duplicate an already-granted capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
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" }),
});
expect(state.grants).toHaveLength(1);
});
it("revokes a granted capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
{ method: "DELETE" },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
granted: false,
});
expect(state.grants).toEqual([]);
});
it("404s revoking a capability that was not granted", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
{ method: "DELETE" },
);
expect(res.status).toBe(404);
});
describe("admin.plugins.manage gate", () => {
it("a non-admin authenticated user cannot 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());
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
expect(res.status).toBe(200);
});
it("403s a non-admin enabling or disabling a plugin", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/state`, {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-test-user-id": "regular-user",
},
body: JSON.stringify({ enabled: false }),
});
expect(res.status).toBe(403);
expect(state.plugins.get("sample-plugin")?.state).toBe("enabled");
});
it("403s a non-admin granting a capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-test-user-id": "regular-user",
},
body: JSON.stringify({ capability: "hosts.read" }),
});
expect(res.status).toBe(403);
expect(state.grants).toEqual([]);
});
it("403s a non-admin revoking a capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
{
method: "DELETE",
headers: { "x-test-user-id": "regular-user" },
},
);
expect(res.status).toBe(403);
expect(state.grants).toHaveLength(1);
});
});
});
@@ -41,6 +41,8 @@ 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 { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
@@ -314,4 +316,165 @@ describe("plugin HTTP bridge", () => {
},
WORKER_TIMEOUT,
);
describe("per-request actor identity", () => {
/**
* Exercises the real worker end to end: the fixture's activate() calls
* ctx.hosts.list() from inside a genuine ctx.http.route handler, so the
* AsyncLocalStorage context worker-bootstrap.ts enters around that
* invocation is the thing under test, not a mock of it. Two concurrent
* requests as two different users must each see only their own hosts,
* even though both are in flight on the very same worker at once.
*/
async function activateWithHostsRoute() {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/my-hosts", async () => {
const hosts = await ctx.hosts.list();
return { hosts };
});
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
alice: [{ id: 1, name: "alice-host" }],
bob: [{ id: 2, name: "bob-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
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);
},
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
// Activated under "install-owner", a third identity distinct from
// alice and bob, so a test that accidentally fell back to ownerUserId
// instead of the per-request actor would show up as a clear mismatch
// rather than an accidental pass.
await loader.activate("sample-plugin", "install-owner");
}
/**
* Mimics authenticateJWT setting req.userId ahead of the dispatcher, the
* way database.ts really wires it (see plugin-api-routes-auth.test.ts for
* that wiring itself). This file's own app has no auth in front of
* pluginApi.default on purpose, to test the dispatcher in isolation, so
* the per-user identity is injected the same minimal way here.
*/
function fetchAsUser(path: string, userId: string) {
return fetch(`${baseUrl}${path}`, {
headers: { "x-test-user-id": userId },
});
}
it(
"two concurrent requests as different users each see only their own hosts",
async () => {
await activateWithHostsRoute();
// Reopen the server with a stand-in auth middleware that reads the
// test header, since the beforeEach server has none.
await new Promise<void>((resolve) => server?.close(() => resolve()));
const app = express();
app.use((req, _res, next) => {
const userId = req.headers["x-test-user-id"];
if (typeof userId === "string") {
(req as typeof req & { userId?: string }).userId = userId;
}
next();
});
app.use("/plugin-api", pluginApi.default);
server = await new Promise((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const [aliceRes, bobRes] = await Promise.all([
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "alice"),
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "bob"),
]);
expect(await aliceRes.json()).toMatchObject({
hosts: [{ id: 1, name: "alice-host" }],
});
expect(await bobRes.json()).toMatchObject({
hosts: [{ id: 2, name: "bob-host" }],
});
},
WORKER_TIMEOUT,
);
it(
"falls back to the install owner when a call has no inbound request",
async () => {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
const hosts = await ctx.hosts.list();
await ctx.log.info(JSON.stringify({ hosts }));
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
"install-owner": [{ id: 9, name: "owner-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
resolveHost: async () => null,
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "install-owner");
const line = logged.find((entry) => entry.startsWith("{"));
expect(line && JSON.parse(line)).toMatchObject({
hosts: [{ id: 9, name: "owner-host" }],
});
},
WORKER_TIMEOUT,
);
});
});
+1
View File
@@ -59,6 +59,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"admin.roles.manage",
"admin.settings.manage",
"admin.sessions.manage",
"admin.plugins.manage",
],
},
];
+22
View File
@@ -21,6 +21,10 @@ export interface PluginSummary {
runtimeState: string;
lastError: string | null;
contributes: PluginContributions | null;
/** Capabilities this plugin's manifest declares it may ask for. */
permissions: string[];
/** The subset of `permissions` an admin has actually granted. */
grantedCapabilities: string[];
}
export async function getPlugins(): Promise<PluginSummary[]> {
@@ -36,3 +40,21 @@ export async function setPluginEnabled(
enabled,
});
}
export async function grantPluginCapability(
pluginId: string,
capability: string,
): Promise<void> {
await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, {
capability,
});
}
export async function revokePluginCapability(
pluginId: string,
capability: string,
): Promise<void> {
await rbacApi.delete(
`/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`,
);
}
+31 -1
View File
@@ -3984,7 +3984,37 @@
"pluginToggleFailed": "Failed to change the plugin",
"pluginBuiltIn": "BUILT IN",
"pluginEnabled": "{{name}} enabled",
"pluginDisabled": "{{name}} disabled"
"pluginDisabled": "{{name}} disabled",
"pluginPermissions": "Permissions",
"pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.",
"pluginPermissionGranted": "Granted",
"pluginPermissionNotGranted": "Not granted",
"pluginGrantFailed": "Failed to grant the capability",
"pluginRevokeFailed": "Failed to revoke the capability",
"pluginPermissionHostsRead": "Read hosts",
"pluginPermissionHostsReadDesc": "See the list of your hosts and their non-secret details.",
"pluginPermissionHostsWrite": "Modify hosts",
"pluginPermissionHostsWriteDesc": "Create, edit or delete hosts on your behalf.",
"pluginPermissionCredentialsUse": "Use stored credentials",
"pluginPermissionCredentialsUseDesc": "Connect using a saved credential without you retyping it.",
"pluginPermissionSshExec": "Run commands over SSH",
"pluginPermissionSshExecDesc": "Open a connection to a host and run commands on it.",
"pluginPermissionSshSftp": "Transfer files over SFTP",
"pluginPermissionSshSftpDesc": "Read or write files on a host over SFTP.",
"pluginPermissionStorageOwn": "Store its own data",
"pluginPermissionStorageOwnDesc": "Save and read back its own settings and data.",
"pluginPermissionStorageSecrets": "Store secrets",
"pluginPermissionStorageSecretsDesc": "Save and read back sensitive values it manages itself.",
"pluginPermissionNetworkOutbound": "Make outbound network requests",
"pluginPermissionNetworkOutboundDesc": "Contact services outside this server.",
"pluginPermissionEventsRead": "Subscribe to server events",
"pluginPermissionEventsReadDesc": "React to things happening elsewhere in Termix.",
"pluginPermissionNotifySend": "Send notifications",
"pluginPermissionNotifySendDesc": "Deliver a notification through your configured channels.",
"pluginPermissionUsersRead": "Read user accounts",
"pluginPermissionUsersReadDesc": "See basic details about accounts on this server.",
"pluginPermissionProcessSidecar": "Run a background process",
"pluginPermissionProcessSidecarDesc": "Start and manage its own helper process."
},
"newUi": {
"sidebar": {
+67 -36
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Puzzle } from "lucide-react";
import { Puzzle, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { SettingRow } from "@/components/section-card";
import { Button } from "@/components/button";
import {
getPlugins,
setPluginEnabled,
@@ -10,6 +11,7 @@ import {
} from "@/api/plugins-api";
import { refreshPluginState } from "@/shell/pluginLoader";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { PluginPermissionsDialog } from "./PluginPermissionsDialog";
export function AdminPluginsSection({
open,
@@ -22,6 +24,11 @@ export function AdminPluginsSection({
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [permissionsTargetId, setPermissionsTargetId] = useState<string | null>(
null,
);
const permissionsTarget =
plugins.find((plugin) => plugin.id === permissionsTargetId) ?? null;
const load = useCallback(async () => {
try {
@@ -57,40 +64,64 @@ export function AdminPluginsSection({
};
return (
<AccordionSection
label={t("admin.plugins")}
icon={<Puzzle className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{plugins.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{loaded ? t("admin.pluginsNone") : t("common.loading")}
</p>
) : (
plugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
}
>
<AdminToggle
on={plugin.enabled}
onToggle={() => void toggle(plugin)}
disabled={busy === plugin.id}
/>
</SettingRow>
))
)}
</AccordionSection>
<>
<AccordionSection
label={t("admin.plugins")}
icon={<Puzzle className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{plugins.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{loaded ? t("admin.pluginsNone") : t("common.loading")}
</p>
) : (
plugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
}
>
<div className="flex items-center gap-2">
{plugin.permissions.length > 0 && (
<Button
variant="outline"
size="sm"
className="rounded-none h-7 px-2 text-[10px] font-bold uppercase tracking-widest"
onClick={() => setPermissionsTargetId(plugin.id)}
>
<ShieldCheck className="size-3" />
{t("admin.pluginPermissions")}
</Button>
)}
<AdminToggle
on={plugin.enabled}
onToggle={() => void toggle(plugin)}
disabled={busy === plugin.id}
/>
</div>
</SettingRow>
))
)}
</AccordionSection>
<PluginPermissionsDialog
plugin={permissionsTarget}
open={permissionsTargetId !== null}
onOpenChange={(next) => {
if (!next) setPermissionsTargetId(null);
}}
onChanged={() => void load()}
/>
</>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldCheck } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
grantPluginCapability,
revokePluginCapability,
type PluginSummary,
} from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
/**
* Every permission a plugin's manifest can declare, with the label and
* one-line description shown here. process:transport-owner is left out on
* purpose: it is reserved for first-party plugins and enforced by a hardcoded
* allowlist, not something an admin grants, so showing a toggle for it would
* be misleading. ui.* permissions describe what a plugin contributes to the
* shell rather than a capability the broker gates, but they are still shown
* here since the manifest can declare them and a plugin's grant list should
* not silently hide part of what it asked for.
*/
const LABELED_PERMISSIONS: Array<{
capability: string;
labelKey: string;
descriptionKey: string;
}> = [
{
capability: "hosts.read",
labelKey: "admin.pluginPermissionHostsRead",
descriptionKey: "admin.pluginPermissionHostsReadDesc",
},
{
capability: "hosts.write",
labelKey: "admin.pluginPermissionHostsWrite",
descriptionKey: "admin.pluginPermissionHostsWriteDesc",
},
{
capability: "credentials.use",
labelKey: "admin.pluginPermissionCredentialsUse",
descriptionKey: "admin.pluginPermissionCredentialsUseDesc",
},
{
capability: "ssh.exec",
labelKey: "admin.pluginPermissionSshExec",
descriptionKey: "admin.pluginPermissionSshExecDesc",
},
{
capability: "ssh.sftp",
labelKey: "admin.pluginPermissionSshSftp",
descriptionKey: "admin.pluginPermissionSshSftpDesc",
},
{
capability: "storage.own",
labelKey: "admin.pluginPermissionStorageOwn",
descriptionKey: "admin.pluginPermissionStorageOwnDesc",
},
{
capability: "storage.secrets",
labelKey: "admin.pluginPermissionStorageSecrets",
descriptionKey: "admin.pluginPermissionStorageSecretsDesc",
},
{
capability: "network.outbound",
labelKey: "admin.pluginPermissionNetworkOutbound",
descriptionKey: "admin.pluginPermissionNetworkOutboundDesc",
},
{
capability: "events.read",
labelKey: "admin.pluginPermissionEventsRead",
descriptionKey: "admin.pluginPermissionEventsReadDesc",
},
{
capability: "notify.send",
labelKey: "admin.pluginPermissionNotifySend",
descriptionKey: "admin.pluginPermissionNotifySendDesc",
},
{
capability: "users.read",
labelKey: "admin.pluginPermissionUsersRead",
descriptionKey: "admin.pluginPermissionUsersReadDesc",
},
{
capability: "process.sidecar",
labelKey: "admin.pluginPermissionProcessSidecar",
descriptionKey: "admin.pluginPermissionProcessSidecarDesc",
},
];
export function PluginPermissionsDialog({
plugin,
open,
onOpenChange,
onChanged,
}: {
plugin: PluginSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState<string | null>(null);
if (!plugin) return null;
const rows = LABELED_PERMISSIONS.filter((row) =>
plugin.permissions.includes(row.capability),
);
const granted = new Set(plugin.grantedCapabilities);
const toggle = async (capability: string, isGranted: boolean) => {
setBusy(capability);
try {
if (isGranted) {
await revokePluginCapability(plugin.id, capability);
} else {
await grantPluginCapability(plugin.id, capability);
}
onChanged();
} catch {
toast.error(
isGranted
? t("admin.pluginRevokeFailed")
: t("admin.pluginGrantFailed"),
);
} finally {
setBusy(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<ShieldCheck className="size-4 text-accent-brand" />
{t("admin.pluginPermissions")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground">
{plugin.name}
</DialogDescription>
</DialogHeader>
<div className="py-3">
{rows.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{t("admin.pluginPermissionsNone")}
</p>
) : (
<div className="border border-border overflow-hidden">
{rows.map((row, i) => {
const isGranted = granted.has(row.capability);
return (
<div
key={row.capability}
className={`flex items-center justify-between gap-3 px-3 py-3 ${
i < rows.length - 1 ? "border-b border-border" : ""
}`}
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-xs font-semibold">
{t(row.labelKey)}
</span>
<span className="text-[11px] text-muted-foreground leading-snug">
{t(row.descriptionKey)}
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-0.5">
{isGranted
? t("admin.pluginPermissionGranted")
: t("admin.pluginPermissionNotGranted")}
</span>
</div>
<AdminToggle
on={isGranted}
onToggle={() => void toggle(row.capability, isGranted)}
disabled={busy === row.capability}
/>
</div>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+2
View File
@@ -43,6 +43,8 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
},
],
},
permissions: [],
grantedCapabilities: [],
...overrides,
};
}