feat: add a permission-gated backend service registry for plugins

This commit is contained in:
LukeGus committed 2026-09-21 16:23:00 -05:00
1 parent ee2d6b4697
commit 225f618337
17 files changed
+1755 -2

No files matched your search

@@ -0,0 +1,284 @@
/**
* Unit coverage for the declared service registry: registration, versioned
* structural resolution, and the per-call permission gate on a handle.
*
* The two-fixture end-to-end test lives in service-contract.test.ts.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const {
clearServiceRegistry,
createServiceHandle,
getRegistration,
listServices,
provideService,
resolveRequirements,
revokeService,
PluginServiceActorError,
PluginServicePermissionError,
PluginServiceUnavailableError,
} = await import("../../plugins/service-registry.js");
type Registration = ReturnType<typeof provideService>;
function register(
overrides: Partial<Parameters<typeof provideService>[0]> = {},
): Registration {
return provideService({
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.greet.use",
pluginId: "provider-plugin",
pluginName: "Provider Plugin",
implementation: { hello: async (name: string) => `hello ${name}` },
...overrides,
});
}
function manifestRequiring(
requires: Array<{
service: string;
versionRange: string;
optional?: boolean;
}>,
) {
return { id: "consumer-plugin", requires } as never;
}
describe("service registry", () => {
beforeEach(() => clearServiceRegistry());
afterEach(() => clearServiceRegistry());
it("round-trips a registration", () => {
const registration = register();
expect(getRegistration("testplugin.greet")).toBe(registration);
expect(listServices()).toHaveLength(1);
expect(registration.generation).toBeGreaterThan(0);
});
it("only revokes the registration that is actually current", () => {
const first = register();
const second = register();
// A crashed-and-restarted provider must not revoke the replacement its own
// restart installed.
expect(revokeService("testplugin.greet", first)).toBe(false);
expect(getRegistration("testplugin.greet")).toBe(second);
expect(revokeService("testplugin.greet", second)).toBe(true);
expect(getRegistration("testplugin.greet")).toBeUndefined();
});
it("reports a revoke of something never registered", () => {
expect(revokeService("nothing.here")).toBe(false);
});
describe("resolution", () => {
it("is satisfied when the version is in range", () => {
register({ version: "1.2.0" });
const resolution = resolveRequirements(
manifestRequiring([
{ service: "testplugin.greet", versionRange: "^1.2.0" },
]),
);
expect(resolution.satisfied).toBe(true);
expect(resolution.errors).toEqual([]);
});
it("fails a hard requirement nothing provides", () => {
const resolution = resolveRequirements(
manifestRequiring([
{ service: "testplugin.greet", versionRange: "^1.0.0" },
]),
);
expect(resolution.satisfied).toBe(false);
expect(resolution.errors[0]).toContain("no active plugin provides");
});
it("fails when the provided version is out of range", () => {
register({ version: "2.0.0" });
const resolution = resolveRequirements(
manifestRequiring([
{ service: "testplugin.greet", versionRange: "^1.0.0" },
]),
);
expect(resolution.satisfied).toBe(false);
expect(resolution.errors[0]).toContain("provides 2.0.0");
});
it("skips an unsatisfied optional requirement", () => {
const resolution = resolveRequirements(
manifestRequiring([
{
service: "testplugin.greet",
versionRange: "^1.0.0",
optional: true,
},
]),
);
expect(resolution.satisfied).toBe(true);
expect(resolution.missingOptional).toEqual(["testplugin.greet"]);
});
it("treats a manifest with no requires as satisfied", () => {
expect(resolveRequirements({ id: "x" } as never).satisfied).toBe(true);
});
});
describe("handle", () => {
function handleFor(allowed: boolean, ...actor: [] | [string | undefined]) {
const userId = actor.length === 0 ? "user-1" : actor[0];
const audits: Array<Record<string, unknown>> = [];
const handle = createServiceHandle<{
hello: (name: string) => Promise<string>;
}>("testplugin.greet", "consumer-plugin", {
resolveUserId: () => userId,
hasPermission: async () => allowed,
audit: (entry) => void audits.push(entry as never),
});
return { handle, audits };
}
it("delegates to the implementation when permitted", async () => {
register();
const { handle, audits } = handleFor(true);
await expect(handle.hello("world")).resolves.toBe("hello world");
expect(audits).toHaveLength(1);
expect(audits[0].success).toBe(true);
});
it("denies without the permission and never reaches the provider", async () => {
const hello = vi.fn(async () => "should not run");
register({ implementation: { hello } });
const { handle, audits } = handleFor(false);
await expect(handle.hello("world")).rejects.toThrow(
PluginServicePermissionError,
);
// The gate has to run before delegation, not after.
expect(hello).not.toHaveBeenCalled();
expect(audits[0].success).toBe(false);
});
it("denies with the same 403 shape requirePermission sends", async () => {
register();
const { handle } = handleFor(false);
await handle.hello("world").then(
() => expect.unreachable("should have denied"),
(error: InstanceType<typeof PluginServicePermissionError>) => {
expect(error.status).toBe(403);
expect(error.body).toEqual({
error: "Insufficient permissions",
required: "testplugin.greet.use",
});
},
);
});
it("refuses a call it cannot attribute to a user", async () => {
register();
const { handle } = handleFor(true, undefined);
await expect(handle.hello("world")).rejects.toThrow(
PluginServiceActorError,
);
});
it("throws once the provider is revoked", async () => {
const registration = register();
const { handle } = handleFor(true);
await expect(handle.hello("world")).resolves.toBe("hello world");
revokeService("testplugin.greet", registration);
expect(() => handle.hello("world")).toThrow(
/no longer provided|is not a function/,
);
});
it("exposes only the functions the provider handed out", () => {
register({
implementation: {
hello: async () => "hi",
secret: "not a function",
},
});
const { handle } = handleFor(true);
expect(typeof handle.hello).toBe("function");
expect(
(handle as unknown as Record<string, unknown>).secret,
).toBeUndefined();
expect(Object.keys(handle)).toEqual(["hello"]);
});
it("rebinds the acting user with asUser", async () => {
register();
const seen: string[] = [];
const handle = createServiceHandle<{
hello: (name: string) => Promise<string>;
asUser: (userId: string) => { hello: (n: string) => Promise<string> };
}>("testplugin.greet", "consumer-plugin", {
resolveUserId: () => undefined,
hasPermission: async (userId) => {
seen.push(userId);
return true;
},
audit: () => {},
});
await handle.asUser("user-42").hello("world");
expect(seen).toEqual(["user-42"]);
});
it("surfaces an unavailable service rather than a generic undefined", () => {
const { handle } = handleFor(true);
expect(
(handle as unknown as Record<string, unknown>).hello,
).toBeUndefined();
});
it("audits a failure thrown by the provider itself", async () => {
register({
implementation: {
hello: async () => {
throw new Error("provider exploded");
},
},
});
const { handle, audits } = handleFor(true);
await expect(handle.hello("world")).rejects.toThrow("provider exploded");
expect(audits[0].success).toBe(false);
expect(audits[0].errorMessage).toBe("provider exploded");
});
});
it("exposes the unavailable error with a stable code", () => {
const error = new PluginServiceUnavailableError("a.b", "provider");
expect(error.code).toBe("EPLUGINSVCGONE");
expect(error.message).toContain("a.b");
expect(error.message).toContain("provider");
});
});