Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/db/src/schema/plugin_company_settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import { plugins } from "./plugins.js";
* plugin.
*
* Rows represent explicit overrides from the default company behavior:
* - no row => plugin is enabled for the company by default
* - no row => the plugin manifest's `companyEnablement.default` applies
* ("on" when the manifest omits it, i.e. enabled by default)
* - row with `enabled = false` => plugin is disabled for that company
* - row with `enabled = true` => plugin remains enabled and stores company settings
* - row with `enabled = true` => plugin is enabled and stores company settings
*
* See server/src/services/plugin-company-enablement.ts for the evaluation.
*/
export const pluginCompanySettings = pgTable(
"plugin_company_settings",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,7 @@ export const PERMISSION_KEYS = [
"tasks:manage_active_checkouts",
"pipelines:write",
"joins:approve",
"plugins:manage",
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];

Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,22 @@ export interface PaperclipPluginManifestV1 {
minimumPaperclipVersion?: PluginMinimumHostVersion;
/** Capabilities this plugin requires from the host. Enforced at runtime. */
capabilities: PluginCapability[];
/**
* Per-company enablement defaults for this plugin.
*
* - Absent ⇒ `default: "on"` (today's behavior; existing plugins unaffected).
* - `default: "off"` makes the plugin opt-in per company: no
* `plugin_company_settings` row ⇒ disabled for that company.
* - `locked: true` ⇒ companies cannot toggle the plugin themselves; the
* effective state is the manifest default unless an instance admin has
* written a per-company override row (governance plugins, e.g. billing).
*
* A `plugin_company_settings` row always overrides the manifest default.
*/
companyEnablement?: {
default: "on" | "off";
locked?: boolean;
};
/** Entrypoint paths relative to the package root. */
entrypoints: {
/** Path to the worker entrypoint (required). */
Expand Down
79 changes: 79 additions & 0 deletions packages/shared/src/validators/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,82 @@ describe("plugin UI slot validators", () => {
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
});

describe("plugin manifest companyEnablement", () => {
const baseManifest = {
id: "paperclip.company-enablement-fixture",
apiVersion: 1,
version: "0.1.0",
displayName: "Company Enablement Fixture",
description: "Manifest fixture for companyEnablement validation.",
author: "Paperclip",
categories: ["ui"],
capabilities: ["ui.dashboardWidget.register"],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui.js",
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "fixture-widget",
displayName: "Fixture Widget",
exportName: "FixtureWidget",
},
],
},
};

it("accepts a manifest without companyEnablement (default on)", () => {
const parsed = pluginManifestV1Schema.parse(baseManifest);
expect(parsed.companyEnablement).toBeUndefined();
});

it("accepts default on and default off", () => {
expect(
pluginManifestV1Schema.parse({
...baseManifest,
companyEnablement: { default: "on" },
}).companyEnablement,
).toEqual({ default: "on" });
expect(
pluginManifestV1Schema.parse({
...baseManifest,
companyEnablement: { default: "off" },
}).companyEnablement,
).toEqual({ default: "off" });
});

it("accepts locked with a default", () => {
const parsed = pluginManifestV1Schema.parse({
...baseManifest,
companyEnablement: { default: "off", locked: true },
});
expect(parsed.companyEnablement).toEqual({ default: "off", locked: true });
});

it("rejects an invalid default value", () => {
const result = pluginManifestV1Schema.safeParse({
...baseManifest,
companyEnablement: { default: "maybe" },
});
expect(result.success).toBe(false);
});

it("rejects companyEnablement without a default", () => {
const result = pluginManifestV1Schema.safeParse({
...baseManifest,
companyEnablement: { locked: true },
});
expect(result.success).toBe(false);
});

it("rejects unknown keys in companyEnablement", () => {
const result = pluginManifestV1Schema.safeParse({
...baseManifest,
companyEnablement: { default: "on", unexpectedKey: true },
});
expect(result.success).toBe(false);
});
});
4 changes: 4 additions & 0 deletions packages/shared/src/validators/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,10 @@ export const pluginManifestV1Schema = z.object({
"minimumPaperclipVersion must follow semver (e.g. 1.0.0)",
).optional(),
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
companyEnablement: z.object({
default: z.enum(["on", "off"]),
locked: z.boolean().optional(),
}).strict().optional(),
entrypoints: z.object({
worker: z.string().min(1),
ui: z.string().min(1).optional(),
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions server/src/__tests__/invite-join-grants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe("human invite roles", () => {
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
{ permissionKey: "plugins:manage", scope: null },
]);
});

Expand All @@ -87,6 +88,7 @@ describe("human invite roles", () => {
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
{ permissionKey: "plugins:manage", scope: null },
]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
companyMemberships,
createDb,
invites,
plugins,
principalPermissionGrants,
} from "@paperclipai/db";
import { buildHostServices } from "../services/plugin-host-services.js";
Expand All @@ -17,7 +18,7 @@ import {

const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const pluginId = "plugin-record-id";
const pluginId = randomUUID();

function createEventBusStub() {
return {
Expand Down Expand Up @@ -49,6 +50,19 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-access-authz-");
db = createDb(tempDb.connectionString);
// The per-company enablement gate (buildHostServices -> ensurePluginAvailableForCompany)
// fails closed on an unknown plugin id, so every buildHostServices(db, pluginId, ...) call
// in this suite needs a matching `plugins` row. No plugin_company_settings row is ever
// seeded for the companies these tests create, so the manifest default ("on", since
// manifestJson is empty here) keeps every existing assertion behaving as before.
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.host-services-gate-fixture",
packageName: "@paperclipai/host-services-gate-fixture",
version: "0.1.0",
manifestJson: {},
status: "ready",
});
}, 20_000);

afterEach(async () => {
Expand Down
136 changes: 136 additions & 0 deletions server/src/__tests__/plugin-host-services-company-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";

const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
getConfig: vi.fn(),
getCompanySettings: vi.fn(),
upsertCompanySettings: vi.fn(),
}));

vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));

import { buildHostServices } from "../services/plugin-host-services.js";

const pluginId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";

// Shape mirrors createEventBusStub in plugin-access-authorization-host-services.test.ts:22.
function createEventBusStub() {
return {
forPlugin() {
return {
emit: vi.fn(),
subscribe: vi.fn(),
clear: vi.fn(),
};
},
} as never;
}

function build() {
// buildHostServices only *captures* db in service factories; no query runs
// until a service method is invoked, so an empty object is sufficient here.
return buildHostServices({} as never, pluginId, "paperclip.example", createEventBusStub());
}

const localFoldersManifest = {
id: "paperclip.example",
apiVersion: 1 as const,
version: "0.1.0",
displayName: "Example",
description: "Fixture plugin for the local-folders gate-ordering test",
author: "Paperclip",
categories: ["automation" as const],
capabilities: ["local.folders" as const],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "docs",
displayName: "Docs root",
access: "readWrite" as const,
},
],
};

function buildWithLocalFolders() {
return buildHostServices(
{} as never,
pluginId,
"paperclip.example",
createEventBusStub(),
undefined,
{ manifest: localFoldersManifest },
);
}

describe("host services per-company enablement gate", () => {
it("rejects company-scoped host calls when the plugin is disabled for the company", async () => {
mockRegistry.getById.mockResolvedValue({ id: pluginId, manifestJson: {} });
mockRegistry.getCompanySettings.mockResolvedValue({ enabled: false });
const services = build();

await expect(services.config.get({ companyId })).rejects.toMatchObject({
status: 403,
details: { code: "plugin_not_enabled_for_company" },
});
expect(mockRegistry.getConfig).not.toHaveBeenCalled();
services.dispose();
});

it("allows company-scoped host calls when no settings row exists (default on)", async () => {
mockRegistry.getById.mockResolvedValue({ id: pluginId, manifestJson: {} });
mockRegistry.getCompanySettings.mockResolvedValue(null);
mockRegistry.getConfig.mockResolvedValue({ configJson: { greeting: "hi" } });
const services = build();

await expect(services.config.get({ companyId })).resolves.toEqual({ greeting: "hi" });
expect(mockRegistry.getCompanySettings).toHaveBeenCalledWith(pluginId, companyId);
services.dispose();
});

it('respects manifest default "off" when no settings row exists', async () => {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
manifestJson: { companyEnablement: { default: "off" } },
});
mockRegistry.getCompanySettings.mockResolvedValue(null);
const services = build();

await expect(services.config.get({ companyId })).rejects.toMatchObject({ status: 403 });
services.dispose();
});

// Extra verification (prior review): plugin-host-services.ts's localFolders.configure
// upserts plugin_company_settings with `enabled: existing?.enabled ?? true` — a
// company-disabled plugin must never reach that upsert. Prove the gate runs first by
// driving configure() with the plugin disabled and asserting upsertCompanySettings
// (and the pre-upsert getCompanySettings read at plugin-host-services.ts:1079) never ran.
it("rejects localFolders.configure before touching plugin_company_settings when the plugin is disabled", async () => {
vi.clearAllMocks(); // isolate the call-count assertions below from earlier tests in this file
mockRegistry.getById.mockResolvedValue({ id: pluginId, manifestJson: {} });
mockRegistry.getCompanySettings.mockResolvedValue({ enabled: false });
const services = buildWithLocalFolders();

await expect(
services.localFolders.configure({
companyId,
folderKey: "docs",
path: "/tmp/does-not-matter",
access: "readWrite",
}),
).rejects.toMatchObject({
status: 403,
details: { code: "plugin_not_enabled_for_company" },
});

// The only permitted read is the gate's own enablement check; configure()'s
// subsequent `existing = await registry.getCompanySettings(...)` read (line 1079)
// must not run, and the upsert (line 1104) must never be reached.
expect(mockRegistry.getCompanySettings).toHaveBeenCalledTimes(1);
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
services.dispose();
});
});
Loading
Loading