diff --git a/packages/db/src/schema/plugin_company_settings.ts b/packages/db/src/schema/plugin_company_settings.ts index 5feefe60c35..2845dc7e4a6 100644 --- a/packages/db/src/schema/plugin_company_settings.ts +++ b/packages/db/src/schema/plugin_company_settings.ts @@ -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", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 8497e9a4345..7b0d3fbdc48 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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]; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 0c090d7acf1..7235fa111b8 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -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). */ diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 8b210885b06..1800d704e09 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 9093c0042f2..50f49dd54f8 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -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(), diff --git a/screenshots/pr2-company-plugins-catalog-locked-row.png b/screenshots/pr2-company-plugins-catalog-locked-row.png new file mode 100644 index 00000000000..6cf7e00c3d3 Binary files /dev/null and b/screenshots/pr2-company-plugins-catalog-locked-row.png differ diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 7b7fa63e016..6d1e4717b35 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -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 }, ]); }); @@ -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 }, ]); }); diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts index 87737816a34..9b5c74db9f5 100644 --- a/server/src/__tests__/plugin-access-authorization-host-services.test.ts +++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts @@ -7,6 +7,7 @@ import { companyMemberships, createDb, invites, + plugins, principalPermissionGrants, } from "@paperclipai/db"; import { buildHostServices } from "../services/plugin-host-services.js"; @@ -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 { @@ -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 () => { diff --git a/server/src/__tests__/plugin-host-services-company-gate.test.ts b/server/src/__tests__/plugin-host-services-company-gate.test.ts new file mode 100644 index 00000000000..ebd457060e3 --- /dev/null +++ b/server/src/__tests__/plugin-host-services-company-gate.test.ts @@ -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(); + }); +}); diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index de642f30e79..059c1c8c2fd 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { and, eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { activityLog, agentWakeupRequests, @@ -27,6 +27,7 @@ import { buildHostServices } from "../services/plugin-host-services.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const pluginId = randomUUID(); function createEventBusStub() { return { @@ -59,6 +60,22 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { db = createDb(tempDb.connectionString); }, 20_000); + // 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 fresh company ids these tests create, so the manifest default ("on", + // since manifestJson is empty here) keeps every existing assertion behaving as before. + beforeEach(async () => { + 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", + }); + }); + afterEach(async () => { await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); tempRoots.length = 0; @@ -146,7 +163,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { }, }); - const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip.workspace", createEventBusStub()); await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({ id: workspaceId, @@ -185,7 +202,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { identifier: `${issuePrefix(companyId)}-blocker`, }); - const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub()); const issue = await services.issues.create({ companyId, title: "Plugin child issue", @@ -220,11 +237,11 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { expect.arrayContaining([ expect.objectContaining({ actorType: "plugin", - actorId: "plugin-record-id", + actorId: pluginId, action: "issue.created", agentId, details: expect.objectContaining({ - sourcePluginId: "plugin-record-id", + sourcePluginId: pluginId, sourcePluginKey: "paperclip.missions", initiatingActorType: "agent", initiatingActorId: agentId, @@ -237,7 +254,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { it("enforces plugin origin namespaces", async () => { const { companyId } = await seedCompanyAndAgent(); - const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub()); const featureIssue = await services.issues.create({ companyId, @@ -266,7 +283,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { it("creates plugin operation issues with the generic operation origin", async () => { const { companyId } = await seedCompanyAndAgent(); - const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub()); const issue = await services.issues.create({ companyId, @@ -574,7 +591,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { executionRunId: runId, }); - const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub()); await expect( services.issues.assertCheckoutOwner({ issueId, @@ -618,7 +635,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { type: "blocks", }); - const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub()); await expect( services.issues.requestWakeup({ issueId: blockedIssueId, @@ -715,7 +732,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { }, ]); - const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); + const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub()); const summary = await services.issues.getOrchestrationSummary({ companyId, issueId: rootIssueId, diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index d02baa97237..9486b74dacd 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { forbidden } from "../errors.js"; const mockRegistry = vi.hoisted(() => ({ getById: vi.fn(), @@ -9,6 +10,7 @@ const mockRegistry = vi.hoisted(() => ({ upsertConfig: vi.fn(), getCompanySettings: vi.fn(), upsertCompanySettings: vi.fn(), + listByStatus: vi.fn(), })); const mockLifecycle = vi.hoisted(() => ({ @@ -44,6 +46,22 @@ vi.mock("../services/live-events.js", () => ({ publishGlobalLiveEvent: vi.fn(), })); +const mockAccess = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("../services/access.js", () => ({ + accessService: () => mockAccess, +})); + +const mockAssertSurfaceExposed = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("../routes/authz.js", async (importOriginal) => ({ + ...(await importOriginal()), + assertSurfaceExposed: mockAssertSurfaceExposed, +})); + async function createApp( actor: Record, loaderOverrides: Record = {}, @@ -145,6 +163,38 @@ function readyPlugin() { }); } +function catalogPluginRecord( + overrides: Record = {}, + manifestOverrides: Record = {}, +) { + return { + id: pluginId, + pluginKey: "paperclip.example", + version: "1.0.0", + status: "ready", + categories: ["workspace"], + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + manifestJson: { + id: "paperclip.example", + displayName: "Example Plugin", + description: "An example plugin", + ui: { + slots: [ + { + type: "companySettingsPage", + id: "settings", + displayName: "Settings", + exportName: "Settings", + routePath: "example", + }, + ], + }, + ...manifestOverrides, + }, + ...overrides, + }; +} + describe.sequential("plugin install and upgrade authz", () => { beforeEach(() => { vi.clearAllMocks(); @@ -457,8 +507,7 @@ describe.sequential("scoped plugin API routes", () => { body: { ok: true }, }), }; - mockRegistry.getById.mockResolvedValue(null); - mockRegistry.getByKey.mockResolvedValue({ + const scopedApiPluginRecord = { id: pluginId, pluginKey: "paperclip.example", version: "1.0.0", @@ -477,7 +526,12 @@ describe.sequential("scoped plugin API routes", () => { }, ], }, - }); + }; + // resolvePlugin() looks the non-UUID pluginId up via getByKey; the + // enablement gate separately re-fetches by the resolved record's uuid + // (`plugin.id`), so both lookups need to resolve for this test's plugin. + mockRegistry.getById.mockResolvedValue(scopedApiPluginRecord); + mockRegistry.getByKey.mockResolvedValue(scopedApiPluginRecord); const { app } = await createApp( { @@ -563,6 +617,81 @@ describe.sequential("plugin local folder routes", () => { expect(res.body.error).toContain("Local folder key is not declared"); expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); }); + + function defaultOffLocalFolderPlugin() { + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey: "paperclip.example", + version: "1.0.0", + status: "ready", + manifestJson: { + id: "paperclip.example", + capabilities: ["local.folders"], + companyEnablement: { default: "off" }, + localFolders: [ + { + folderKey: "content-root", + displayName: "Content root", + access: "readWrite", + requiredDirectories: ["docs"], + requiredFiles: ["README.md"], + }, + ], + }, + }); + } + + it("rejects PUT local-folders for a member when the plugin is not enabled for the company (403, no upsert)", async () => { + defaultOffLocalFolderPlugin(); + mockRegistry.getCompanySettings.mockResolvedValue(null); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/content-root`) + .send({ path: "/tmp" }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("plugin_not_enabled_for_company"); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); + + it("rejects GET local-folders (list) for a member when the plugin is not enabled for the company", async () => { + defaultOffLocalFolderPlugin(); + mockRegistry.getCompanySettings.mockResolvedValue(null); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/companies/${companyA}/local-folders`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("plugin_not_enabled_for_company"); + }); + + it("rejects GET local-folders/:folderKey/status for a member when the plugin is not enabled for the company", async () => { + defaultOffLocalFolderPlugin(); + mockRegistry.getCompanySettings.mockResolvedValue(null); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/content-root/status`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("plugin_not_enabled_for_company"); + }); + + it("allows PUT local-folders once the plugin is enabled for the company (settings row present)", async () => { + defaultOffLocalFolderPlugin(); + mockRegistry.getCompanySettings.mockResolvedValue({ enabled: true, settingsJson: null, lastError: null }); + mockRegistry.upsertCompanySettings.mockResolvedValue({ enabled: true }); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/content-root`) + .send({ path: "/tmp" }); + + expect(res.status).toBe(200); + expect(mockRegistry.upsertCompanySettings).toHaveBeenCalled(); + }); }); describe.sequential("plugin tool and bridge authz", () => { @@ -1137,3 +1266,488 @@ describe.sequential("plugin tool and bridge authz", () => { expect(executeTool).not.toHaveBeenCalled(); }); }); + +describe.sequential("company plugin catalog and enablement authz", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAssertSurfaceExposed.mockResolvedValue(undefined); + mockAccess.canUser.mockResolvedValue(true); + mockAccess.hasPermission.mockResolvedValue(true); + }); + + it("lists catalog items with manifest-aware enabled state and metadata", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([catalogPluginRecord()]); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/companies/${companyA}/catalog`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + pluginId, + pluginKey: "paperclip.example", + displayName: "Example Plugin", + version: "1.0.0", + description: "An example plugin", + capabilities: [], + enabled: false, + locked: false, + defaultEnabled: true, + hasCompanySettingsPage: true, + settingsRoutePath: "example", + }, + ]); + expect(mockRegistry.listByStatus).toHaveBeenCalledWith("ready"); + }); + + it("reports default-off plugins as disabled when no row exists", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([ + catalogPluginRecord({}, { companyEnablement: { default: "off" } }), + ]); + mockRegistry.getCompanySettings.mockResolvedValueOnce(null); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/companies/${companyA}/catalog`); + + expect(res.status).toBe(200); + expect(res.body[0]).toMatchObject({ enabled: false, defaultEnabled: false, locked: false }); + }); + + it("excludes sandbox-provider-only infrastructure plugins from the catalog", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([ + catalogPluginRecord( + { id: "99999999-9999-4999-8999-999999999999", pluginKey: "paperclip.kubernetes-sandbox-provider" }, + { + ui: undefined, + environmentDrivers: [ + { driverKey: "kubernetes", kind: "sandbox_provider", displayName: "Kubernetes" }, + ], + }, + ), + catalogPluginRecord(), + ]); + mockRegistry.getCompanySettings.mockResolvedValueOnce(null); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/companies/${companyA}/catalog`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].pluginKey).toBe("paperclip.example"); + }); + + it("requires the company.plugins surface (PR-1 gate) on catalog reads", async () => { + mockAssertSurfaceExposed.mockImplementationOnce(async () => { + throw forbidden("Surface is not exposed", { code: "surface_not_exposed" }); + }); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/companies/${companyA}/catalog`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("surface_not_exposed"); + expect(mockAssertSurfaceExposed).toHaveBeenCalledWith( + expect.anything(), + "company.plugins", + expect.any(Function), + ); + expect(mockRegistry.listByStatus).not.toHaveBeenCalled(); + }); + + it("rejects catalog reads from a member of another company", async () => { + const { app } = await createApp(boardActor({ companyIds: [companyB] })); + + const res = await request(app).get(`/api/plugins/companies/${companyA}/catalog`); + + expect(res.status).toBe(403); + expect(mockRegistry.listByStatus).not.toHaveBeenCalled(); + }); + + it("toggles enablement, preserving settingsJson and lastError", async () => { + mockRegistry.getById.mockResolvedValue(catalogPluginRecord()); + mockRegistry.getCompanySettings.mockResolvedValue({ + enabled: true, + settingsJson: { keep: "me" }, + lastError: "previous failure", + }); + mockRegistry.upsertCompanySettings.mockResolvedValueOnce({ + enabled: false, + settingsJson: { keep: "me" }, + lastError: "previous failure", + }); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/enablement`) + .send({ enabled: false }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ pluginId, enabled: false }); + expect(mockAccess.canUser).toHaveBeenCalledWith(companyA, "user-1", "plugins:manage"); + expect(mockRegistry.upsertCompanySettings).toHaveBeenCalledWith( + pluginId, + companyA, + { enabled: false, settingsJson: { keep: "me" }, lastError: "previous failure" }, + ); + }); + + it("rejects toggles from members without plugins:manage", async () => { + mockAccess.canUser.mockResolvedValue(false); + mockRegistry.getById.mockResolvedValue(catalogPluginRecord()); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/enablement`) + .send({ enabled: false }); + + expect(res.status).toBe(403); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); + + it("rejects toggles from viewers before the permission check (write-path company access)", async () => { + const { app } = await createApp(boardActor({ + memberships: [ + { companyId: companyA, status: "active", membershipRole: "viewer" }, + ], + })); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/enablement`) + .send({ enabled: false }); + + expect(res.status).toBe(403); + expect(mockAccess.canUser).not.toHaveBeenCalled(); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); + + it("returns 409 plugin_enablement_locked when a non-admin toggles a locked plugin", async () => { + mockRegistry.getById.mockResolvedValue( + catalogPluginRecord({}, { companyEnablement: { default: "off", locked: true } }), + ); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/enablement`) + .send({ enabled: true }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe("plugin_enablement_locked"); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); + + it("lets an instance admin toggle a locked plugin", async () => { + mockRegistry.getById.mockResolvedValue( + catalogPluginRecord({}, { companyEnablement: { default: "off", locked: true } }), + ); + mockRegistry.getCompanySettings.mockResolvedValue(null); + mockRegistry.upsertCompanySettings.mockResolvedValueOnce({ + enabled: true, + settingsJson: {}, + lastError: null, + }); + const { app } = await createApp(boardActor({ isInstanceAdmin: true })); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/enablement`) + .send({ enabled: true }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ enabled: true, locked: true, defaultEnabled: false }); + expect(mockRegistry.upsertCompanySettings).toHaveBeenCalledWith( + pluginId, + companyA, + { enabled: true, settingsJson: {}, lastError: null }, + ); + }); + + it("rejects enablement toggles from a member of another company", async () => { + const { app } = await createApp(boardActor({ companyIds: [companyB] })); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/enablement`) + .send({ enabled: false }); + + expect(res.status).toBe(403); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); + + it("rejects a non-boolean enabled value", async () => { + mockRegistry.getById.mockResolvedValue(catalogPluginRecord()); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/enablement`) + .send({ enabled: "yes" }); + + expect(res.status).toBe(400); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); +}); + +describe.sequential("per-company plugin enablement on bridge routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the typed 403 for bridge/data when the plugin is disabled for the company", async () => { + readyPlugin(); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const call = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { workerManager: { call } }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/bridge/data`) + .send({ companyId: companyA, key: "health", params: {} }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("plugin_not_enabled_for_company"); + expect(call).not.toHaveBeenCalled(); + }); + + it("allows bridge/data when no company settings row exists", async () => { + readyPlugin(); + mockRegistry.getCompanySettings.mockResolvedValueOnce(null); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { workerManager: { call } }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/bridge/data`) + .send({ companyId: companyA, key: "health", params: {} }); + + expect(res.status).not.toBe(403); + expect(call).toHaveBeenCalled(); + }); + + it("does not gate instance-scoped bridge/data calls (no companyId)", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp( + boardActor({ userId: "admin-1", isInstanceAdmin: true, companyIds: [] }), + {}, + { bridgeDeps: { workerManager: { call } } }, + ); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/bridge/data`) + .send({ key: "health", params: {} }); + + expect(mockRegistry.getCompanySettings).not.toHaveBeenCalled(); + expect(res.status).not.toBe(403); + }); + + it("returns 403 for bridge/action when the plugin is disabled for the company", async () => { + readyPlugin(); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const call = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { workerManager: { call } }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/bridge/action`) + .send({ companyId: companyA, key: "sync", params: {} }); + + expect(res.status).toBe(403); + expect(call).not.toHaveBeenCalled(); + }); + + it("returns 403 for data/:key when the plugin is disabled for the company", async () => { + readyPlugin(); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const call = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { workerManager: { call } }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/data/health`) + .send({ companyId: companyA, params: {} }); + + expect(res.status).toBe(403); + expect(call).not.toHaveBeenCalled(); + }); + + it("returns 403 for actions/:key when the plugin is disabled for the company", async () => { + readyPlugin(); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const call = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { workerManager: { call } }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ companyId: companyA, params: {} }); + + expect(res.status).toBe(403); + expect(call).not.toHaveBeenCalled(); + }); + + it("returns 403 for the bridge SSE stream when the plugin is disabled for the company", async () => { + readyPlugin(); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const subscribe = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { streamBus: { subscribe } }, + }); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/bridge/stream/updates`) + .query({ companyId: companyA }); + + expect(res.status).toBe(403); + expect(subscribe).not.toHaveBeenCalled(); + }); +}); + +function uiContributionPluginRecord(manifestOverrides: Record = {}) { + return catalogPluginRecord({}, { + entrypoints: { worker: "./dist/worker.js", ui: "dist/ui" }, + ...manifestOverrides, + }); +} + +describe.sequential("per-company plugin enablement on tool execution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function toolDeps(executeTool: ReturnType) { + return { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool: vi.fn(() => ({ name: "paperclip.example:search", pluginDbId: pluginId })), + executeTool, + }, + }; + } + + function scopeDb() { + // Three select queues consumed by validateToolRunContextScope: + // project -> agent -> run (each scoped to companyA). + return createSelectQueueDb([ + [{ companyId: companyA }], + [{ companyId: companyA, agentId: agentA }], + [{ companyId: companyA }], + ]); + } + + it("rejects tool execution when the owning plugin is disabled for the run's company", async () => { + const executeTool = vi.fn(); + mockRegistry.getByKey.mockResolvedValue({ id: pluginId, pluginKey: "paperclip.example" }); + mockRegistry.getById.mockResolvedValue({ id: pluginId, pluginKey: "paperclip.example" }); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const { app } = await createApp(agentActor(), {}, { + db: scopeDb(), + toolDeps: toolDeps(executeTool), + }); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "paperclip.example:search", + parameters: { q: "test" }, + runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA }, + }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("plugin_not_enabled_for_company"); + expect(mockRegistry.getByKey).toHaveBeenCalledWith("paperclip.example"); + expect(mockRegistry.getCompanySettings).toHaveBeenCalledWith(pluginId, companyA); + expect(executeTool).not.toHaveBeenCalled(); + }); + + it("allows tool execution when no company settings row exists for the owning plugin", async () => { + const executeTool = vi.fn().mockResolvedValue({ content: "ok" }); + mockRegistry.getByKey.mockResolvedValue({ id: pluginId, pluginKey: "paperclip.example" }); + mockRegistry.getById.mockResolvedValue({ id: pluginId, pluginKey: "paperclip.example" }); + mockRegistry.getCompanySettings.mockResolvedValueOnce(null); + const { app } = await createApp(agentActor(), {}, { + db: scopeDb(), + toolDeps: toolDeps(executeTool), + }); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "paperclip.example:search", + parameters: { q: "test" }, + runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA }, + }); + + expect(res.status).not.toBe(403); + expect(executeTool).toHaveBeenCalled(); + }); +}); + +describe.sequential("GET /plugins/ui-contributions company filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("filters contributions from plugins disabled for the company", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([uiContributionPluginRecord()]); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/ui-contributions?companyId=${companyA}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + expect(mockRegistry.getCompanySettings).toHaveBeenCalledWith(pluginId, companyA); + }); + + it("filters default-off plugins with no settings row (manifest-aware)", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([ + uiContributionPluginRecord({ companyEnablement: { default: "off" } }), + ]); + mockRegistry.getCompanySettings.mockResolvedValueOnce(null); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/ui-contributions?companyId=${companyA}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("includes contributions still enabled for the company", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([uiContributionPluginRecord()]); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: true }); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/ui-contributions?companyId=${companyA}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ pluginId, pluginKey: "paperclip.example" }), + ]); + }); + + it("returns contributions unchanged without a companyId, without settings lookups", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([uiContributionPluginRecord()]); + const { app } = await createApp(boardActor()); + + const res = await request(app).get("/api/plugins/ui-contributions"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ pluginId, pluginKey: "paperclip.example" }), + ]); + expect(mockRegistry.getCompanySettings).not.toHaveBeenCalled(); + }); + + it("rejects a companyId the actor does not belong to", async () => { + mockRegistry.listByStatus.mockResolvedValueOnce([uiContributionPluginRecord()]); + const { app } = await createApp(boardActor({ companyIds: [companyB] })); + + const res = await request(app).get(`/api/plugins/ui-contributions?companyId=${companyA}`); + + expect(res.status).toBe(403); + expect(mockRegistry.getCompanySettings).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/plugin-scoped-api-routes.test.ts b/server/src/__tests__/plugin-scoped-api-routes.test.ts index 4e2ce297bc0..fcffc964fab 100644 --- a/server/src/__tests__/plugin-scoped-api-routes.test.ts +++ b/server/src/__tests__/plugin-scoped-api-routes.test.ts @@ -6,6 +6,7 @@ import { pluginManifestV1Schema, type PaperclipPluginManifestV1 } from "@papercl const mockRegistry = vi.hoisted(() => ({ getById: vi.fn(), getByKey: vi.fn(), + getCompanySettings: vi.fn(), })); const mockLifecycle = vi.hoisted(() => ({ @@ -474,4 +475,74 @@ describe.sequential("plugin scoped API routes", () => { "path must stay inside the plugin api namespace", ); }); + + it("returns 403 and does not dispatch when the plugin is disabled for the company", async () => { + const apiRoutes = manifest([ + { + routeKey: "summary.get", + method: "GET", + path: "/summary", + auth: "board", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ]); + mockRegistry.getCompanySettings.mockResolvedValueOnce({ enabled: false }); + const { app, workerManager } = await createApp({ + actor: { + type: "board", + userId: "user-1", + source: "local_implicit", + isInstanceAdmin: true, + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + }); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/api/summary?companyId=${companyId}`); + + expect(res.status).toBe(403); + expect(mockRegistry.getCompanySettings).toHaveBeenCalledWith(pluginId, companyId); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + + it("dispatches when no company settings row exists for the plugin", async () => { + const apiRoutes = manifest([ + { + routeKey: "summary.get", + method: "GET", + path: "/summary", + auth: "board", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ]); + mockRegistry.getCompanySettings.mockResolvedValueOnce(null); + const { app, workerManager } = await createApp({ + actor: { + type: "board", + userId: "user-1", + source: "local_implicit", + isInstanceAdmin: true, + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + }); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/api/summary?companyId=${companyId}`); + + expect(res.status).not.toBe(403); + expect(mockRegistry.getCompanySettings).toHaveBeenCalledWith(pluginId, companyId); + expect(workerManager.call).toHaveBeenCalled(); + }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 9d370340324..9d8e23bd8f9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -69,6 +69,7 @@ import { decideBundledPluginAction } from "./services/bundled-plugin-heal.js"; import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js"; import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js"; import { createPluginEventBus } from "./services/plugin-event-bus.js"; +import { createPluginEventDeliverabilityChecker } from "./services/plugin-company-enablement.js"; import { setPluginEventBus } from "./services/activity-log.js"; import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js"; import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js"; @@ -287,7 +288,12 @@ export async function createApp( api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); } const pluginRegistry = pluginRegistryService(db); - const eventBus = createPluginEventBus(); + const eventBus = createPluginEventBus({ + isPluginDeliverableForCompany: createPluginEventDeliverabilityChecker( + pluginRegistry, + (ctx, msg) => logger.warn(ctx, msg), + ), + }); setPluginEventBus(eventBus); const jobStore = pluginJobStore(db); const lifecycle = pluginLifecycleManager(db, { workerManager }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a2febd1297d..4e97e066d00 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4717,6 +4717,27 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/plugins/companies/{companyId}/catalog", + tags: ["plugins"], + summary: "Get company-scoped plugin catalog with enablement state", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "put", + path: "/api/plugins/{pluginId}/companies/{companyId}/enablement", + tags: ["plugins"], + summary: "Toggle plugin enablement for a company", + request: { + params: z.object({ pluginId: z.string(), companyId: z.string() }), + body: jsonBody(z.object({ enabled: z.boolean() })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + // ─── Instance database backups ──────────────────────────────────────────────── registry.registerPath({ diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 792656b285c..0c196f57341 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -41,11 +41,18 @@ import type { PaperclipPluginManifestV1, PluginBridgeErrorCode, PluginLauncherRenderContextSnapshot, + PluginRecord, } from "@paperclipai/shared"; import { PLUGIN_STATUSES, } from "@paperclipai/shared"; import { pluginRegistryService } from "../services/plugin-registry.js"; +import { accessService } from "../services/access.js"; +import { + evaluateCompanyEnablement, + pluginCompanyEnablementService, +} from "../services/plugin-company-enablement.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; import { getPluginUiContributionMetadata, @@ -71,6 +78,7 @@ import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, + assertSurfaceExposed, getActorInfo, } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; @@ -85,7 +93,7 @@ import { extractSecretRefBindingsFromConfig, } from "../services/plugin-secrets-handler.js"; import { secretService } from "../services/secrets.js"; -import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; +import { badRequest, conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; /** UI slot declaration extracted from plugin manifest */ type PluginUiSlotDeclaration = NonNullable["slots"]>[number]; @@ -112,6 +120,26 @@ type PluginUiContribution = { launchers: PluginLauncherDeclaration[]; }; +/** + * Company-facing catalog item combining a `ready` plugin's manifest + * metadata with its per-company enablement state. Returned by the company + * plugin catalog and enablement routes; consumed by the CompanyPlugins UI + * page (ui/src/pages/CompanyPlugins.tsx). + */ +type CompanyPluginCatalogItem = { + pluginId: string; + pluginKey: string; + displayName: string; + version: string; + description: string | null; + capabilities: string[]; + enabled: boolean; + locked: boolean; + defaultEnabled: boolean; + hasCompanySettingsPage: boolean; + settingsRoutePath: string | null; +}; + /** Request body for POST /api/plugins/install */ interface PluginInstallRequest { /** npm package name (e.g., @paperclip/plugin-linear) or local path */ @@ -493,6 +521,8 @@ interface PluginToolExecuteRequest { * | POST | /plugins/:pluginId/actions/:key | Proxy performAction to plugin worker (key in URL) | * | GET | /plugins/:pluginId/bridge/stream/:channel | SSE stream from worker to UI | * | GET | /plugins/:pluginId/dashboard | Aggregated health dashboard data | + * | GET | /plugins/companies/:companyId/catalog | Company-scoped catalog of ready plugins with enabled state | + * | PUT | /plugins/:pluginId/companies/:companyId/enablement | Toggle a plugin's enabled state for a company | * * **Route Ordering Note:** Static routes (like /ui-contributions, /tools) must be * registered before parameterized routes (like /:pluginId) to prevent Express from @@ -516,11 +546,16 @@ export function pluginRoutes( ) { const router = Router(); const registry = pluginRegistryService(db); + const enablement = pluginCompanyEnablementService(registry); const lifecycle = pluginLifecycleManager(db, { loader, workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager, }); const issuesSvc = issueService(db); + const access = accessService(db); + const instanceSettingsSvc = instanceSettingsService(db); + const getExposedCompanySurfaces = async () => + (await instanceSettingsSvc.getVisibility()).companySurfaces; function matchScopedApiRoute(route: PluginApiRouteDeclaration, method: string, requestPath: string) { if (route.method !== method) return null; @@ -716,6 +751,44 @@ export function pluginRoutes( return companyId; } + /** + * Company-scoped bridge invocations additionally require the plugin to + * be enabled for that company (manifest companyEnablement default + + * plugin_company_settings). Instance-scoped invocations (no companyId, + * instance-admin-only per assertPluginBridgeScope) are unaffected. + */ + async function assertPluginBridgeScopeWithEnablement( + req: Request, + pluginRecordId: string, + companyId: unknown, + ): Promise { + const scopedCompanyId = assertPluginBridgeScope(req, companyId); + if (scopedCompanyId !== undefined) { + await enablement.ensurePluginEnabledForCompany(pluginRecordId, scopedCompanyId); + } + return scopedCompanyId; + } + + /** Board actor that is an instance admin (mirrors authz.assertInstanceAdmin). */ + function isInstanceAdminActor(req: Request): boolean { + return req.actor.type === "board" + && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true); + } + + /** + * `plugins:manage` gate for the company enablement toggle. Owner/admin + * memberships hold it implicitly via role default grants; other + * principals need an explicit principal_permission_grants row. + * Enforcement shape mirrors assertCompanyPermission in routes/access.ts. + * Board-only: these management routes sit behind assertBoardOrgAccess; agent principals are intentionally not eligible. + */ + async function assertPluginsManagePermission(req: Request, companyId: string): Promise { + if (req.actor.type !== "board") throw unauthorized(); + if (isInstanceAdminActor(req)) return; + const allowed = await access.canUser(companyId, req.actor.userId, "plugins:manage"); + if (!allowed) throw forbidden('Permission "plugins:manage" is required'); + } + function requirePluginConfigCompanyId(req: Request, companyId: unknown): string { if (typeof companyId !== "string" || companyId.trim().length === 0) { throw badRequest('"companyId" is required and must be a non-empty string'); @@ -866,6 +939,7 @@ export function pluginRoutes( * * Return UI contributions from all plugins in 'ready' state. * Used by the frontend to discover plugin UI slots and launcher metadata. + * Pass ?companyId= to additionally filter out contributions from plugins disabled for that company (asserts company access). * * The response is normalized for the frontend slot host: * - Only includes plugins with at least one declared UI slot or launcher @@ -900,10 +974,13 @@ export function pluginRoutes( */ router.get("/plugins/ui-contributions", async (req, res) => { assertBoardOrgAccess(req); + const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined; + if (companyId) assertCompanyAccess(req, companyId); + const plugins = await registry.listByStatus("ready"); - const contributions: PluginUiContribution[] = plugins - .map((plugin) => { + const mapped = await Promise.all( + plugins.map(async (plugin) => { // Safety check: manifestJson should always exist for ready plugins, but guard against null const manifest = plugin.manifestJson; if (!manifest) return null; @@ -911,6 +988,13 @@ export function pluginRoutes( const uiMetadata = getPluginUiContributionMetadata(manifest); if (!uiMetadata) return null; + // Per-company filtering: slots from plugins disabled for this + // company never reach that company's UI (manifest default aware). + if (companyId) { + const settings = await registry.getCompanySettings(plugin.id, companyId); + if (!evaluateCompanyEnablement(manifest, settings)) return null; + } + return { pluginId: plugin.id, pluginKey: plugin.pluginKey, @@ -921,8 +1005,11 @@ export function pluginRoutes( slots: uiMetadata.slots, launchers: uiMetadata.launchers, }; - }) - .filter((item): item is PluginUiContribution => item !== null); + }), + ); + const contributions: PluginUiContribution[] = mapped.filter( + (item): item is PluginUiContribution => item !== null, + ); res.json(contributions); }); @@ -1029,6 +1116,20 @@ export function pluginRoutes( return; } + // Per-company plugin enablement: the owning plugin must be enabled for + // the run's company before ANY dispatch path (tool gateway or direct + // dispatcher) executes the tool. Tool names are namespaced as + // ":" (plugin-tool-dispatcher.ts), so resolve the + // owner by key; unknown keys fall through to the existing 404 handling. + const namespaceSeparator = tool.indexOf(":"); + const owningPluginKey = namespaceSeparator > 0 ? tool.slice(0, namespaceSeparator) : null; + if (owningPluginKey) { + const owningPlugin = await registry.getByKey(owningPluginKey); + if (owningPlugin) { + await enablement.ensurePluginEnabledForCompany(owningPlugin.id, runContext.companyId); + } + } + if (req.actor.type === "agent" && toolGatewayDeps) { try { const result = await toolGatewayDeps.toolGateway.executePluginTool({ @@ -1374,7 +1475,7 @@ export function pluginRoutes( return; } - const companyId = assertPluginBridgeScope(req, body.companyId); + const companyId = await assertPluginBridgeScopeWithEnablement(req, plugin.id, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1467,7 +1568,7 @@ export function pluginRoutes( return; } - const companyId = assertPluginBridgeScope(req, body.companyId); + const companyId = await assertPluginBridgeScopeWithEnablement(req, plugin.id, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1561,7 +1662,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - const companyId = assertPluginBridgeScope(req, body?.companyId); + const companyId = await assertPluginBridgeScopeWithEnablement(req, plugin.id, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1651,7 +1752,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - const companyId = assertPluginBridgeScope(req, body?.companyId); + const companyId = await assertPluginBridgeScopeWithEnablement(req, plugin.id, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1727,6 +1828,7 @@ export function pluginRoutes( } assertCompanyAccess(req, companyId); + await enablement.ensurePluginEnabledForCompany(plugin.id, companyId); // Set SSE headers res.writeHead(200, { @@ -1816,6 +1918,7 @@ export function pluginRoutes( return; } assertCompanyAccess(req, companyId); + await enablement.ensurePluginEnabledForCompany(plugin.id, companyId); await enforceScopedApiCheckout(req, match.route, match.params, companyId); if (req.method !== "GET" && req.headers["content-type"] && !req.is("application/json")) { res.status(415).json({ error: "Plugin API routes accept JSON requests only" }); @@ -2760,6 +2863,7 @@ export function pluginRoutes( res.status(404).json({ error: "Plugin not found" }); return; } + await enablement.ensurePluginEnabledForCompany(plugin.id, companyId); const settings = await registry.getCompanySettings(plugin.id, companyId); const storedFolders = getStoredLocalFolders(settings?.settingsJson); @@ -2791,6 +2895,7 @@ export function pluginRoutes( res.status(404).json({ error: "Plugin not found" }); return; } + await enablement.ensurePluginEnabledForCompany(plugin.id, companyId); const settings = await registry.getCompanySettings(plugin.id, companyId); const storedFolders = getStoredLocalFolders(settings?.settingsJson); @@ -2847,6 +2952,7 @@ export function pluginRoutes( res.status(404).json({ error: "Plugin not found" }); return; } + await enablement.ensurePluginEnabledForCompany(plugin.id, companyId); const body = req.body as { path?: unknown; @@ -2892,6 +2998,141 @@ export function pluginRoutes( res.json(status); }); + // =========================================================================== + // Company plugin catalog & enablement + // =========================================================================== + + /** Route path of a plugin's declared `companySettingsPage` UI slot, if any. */ + function companySettingsPageRoutePath(manifest: PaperclipPluginManifestV1): string | null { + const page = manifest.ui?.slots?.find( + (slot) => slot.type === "companySettingsPage" && slot.routePath, + ); + return page?.routePath ?? null; + } + + /** + * Infrastructure plugins (sandbox providers / credential brokers) have no + * company-facing surface, so they stay out of the company catalog. This + * codebase identifies them via `environmentDrivers[].kind === + * "sandbox_provider"` (there is no dedicated manifest category) — the same + * predicate as isSandboxProviderOnly in ui/src/components/CompanySettingsSidebar.tsx. + */ + function isInfrastructureOnlyPlugin(manifest: PaperclipPluginManifestV1 | null): boolean { + const drivers = manifest?.environmentDrivers ?? []; + if (drivers.length === 0) return false; + return drivers.every((driver) => driver.kind === "sandbox_provider"); + } + + function toCompanyPluginCatalogItem( + plugin: PluginRecord, + settings: { enabled: boolean } | null, + ): CompanyPluginCatalogItem { + const manifest = plugin.manifestJson; + const settingsRoutePath = companySettingsPageRoutePath(manifest); + return { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + displayName: manifest.displayName ?? plugin.pluginKey, + version: plugin.version, + description: manifest.description ?? null, + capabilities: manifest.capabilities ?? [], + enabled: evaluateCompanyEnablement(manifest, settings), + locked: manifest.companyEnablement?.locked === true, + defaultEnabled: (manifest.companyEnablement?.default ?? "on") === "on", + hasCompanySettingsPage: settingsRoutePath !== null, + settingsRoutePath, + }; + } + + /** + * GET /api/plugins/companies/:companyId/catalog + * + * Company-facing catalog of `ready`, catalog-eligible plugins annotated + * with per-company enablement state (manifest `companyEnablement` default + * + plugin_company_settings row), lock state, and the route path of a + * declared `companySettingsPage` slot. + * + * Authz: active company access + the PR-1 `company.plugins` settings + * surface. Infrastructure (sandbox-provider-only) plugins are excluded. + * + * Response: `CompanyPluginCatalogItem[]` + */ + router.get("/plugins/companies/:companyId/catalog", async (req, res) => { + assertBoardOrgAccess(req); + const { companyId } = req.params; + assertCompanyAccess(req, companyId); + await assertSurfaceExposed(req, "company.plugins", getExposedCompanySurfaces); + + const plugins = await registry.listByStatus("ready"); + const items = await Promise.all( + plugins + .filter((plugin) => !isInfrastructureOnlyPlugin(plugin.manifestJson)) + .map(async (plugin) => + toCompanyPluginCatalogItem( + plugin, + await registry.getCompanySettings(plugin.id, companyId), + )), + ); + res.json(items); + }); + + /** + * PUT /api/plugins/:pluginId/companies/:companyId/enablement + * + * Toggle whether a plugin is enabled for a company. + * + * Authz: active company access (write path) + `plugins:manage` + * (implicitly held by owner/admin memberships, grantable via + * principal_permission_grants; instance admins bypass). + * + * Locked plugins (`manifest.companyEnablement.locked`) reject non-admin + * toggles with 409 `plugin_enablement_locked`; only instance admins may + * write per-company overrides for them. + * + * Reads the existing plugin_company_settings row and round-trips its + * `settingsJson`/`lastError` — the registry upsert overwrites both + * wholesale, so this route must preserve them. + * + * Body: `{ enabled: boolean }` + * Response: `CompanyPluginCatalogItem` (the updated item) + * Errors: 400 non-boolean `enabled`, 404 unknown plugin, 409 locked. + */ + router.put("/plugins/:pluginId/companies/:companyId/enablement", async (req, res) => { + assertBoardOrgAccess(req); + const { companyId } = req.params; + assertCompanyAccess(req, companyId); + await assertPluginsManagePermission(req, companyId); + + const plugin = await resolvePlugin(registry, req.params.pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const { enabled } = (req.body ?? {}) as { enabled?: unknown }; + if (typeof enabled !== "boolean") { + throw badRequest('"enabled" must be a boolean'); + } + + if (plugin.manifestJson?.companyEnablement?.locked === true && !isInstanceAdminActor(req)) { + throw conflict("Plugin enablement is managed by the instance", { + code: "plugin_enablement_locked", + }); + } + + const existing = await registry.getCompanySettings(plugin.id, companyId); + const updated = await registry.upsertCompanySettings(plugin.id, companyId, { + enabled, + settingsJson: existing?.settingsJson ?? {}, + lastError: existing?.lastError ?? null, + }); + await logPluginMutationActivity( + req, + enabled ? "plugin.company_enabled" : "plugin.company_disabled", + plugin.id, + { companyId }, + ); + + res.json(toCompanyPluginCatalogItem(plugin, updated)); + }); + // =========================================================================== // Plugin health dashboard — aggregated diagnostics for the settings page // =========================================================================== diff --git a/server/src/services/company-member-roles.test.ts b/server/src/services/company-member-roles.test.ts new file mode 100644 index 00000000000..4648c3c797a --- /dev/null +++ b/server/src/services/company-member-roles.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { PERMISSION_KEYS } from "@paperclipai/shared"; +import type { HumanCompanyMembershipRole } from "@paperclipai/shared"; +import { grantsForHumanRole } from "./company-member-roles.js"; + +function keysFor(role: HumanCompanyMembershipRole): string[] { + return grantsForHumanRole(role).map((grant) => grant.permissionKey); +} + +describe("plugins:manage permission key", () => { + it("is a registered permission key", () => { + expect(PERMISSION_KEYS).toContain("plugins:manage"); + }); + + it("is implicitly granted to owner and admin roles", () => { + expect(keysFor("owner")).toContain("plugins:manage"); + expect(keysFor("admin")).toContain("plugins:manage"); + }); + + it("is not implicitly granted to operator or viewer roles", () => { + expect(keysFor("operator")).not.toContain("plugins:manage"); + expect(keysFor("viewer")).not.toContain("plugins:manage"); + }); +}); diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index 0c3057c07db..7d739a55e3e 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -35,6 +35,7 @@ export function grantsForHumanRole( { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "plugins:manage", scope: null }, ]; case "admin": return [ @@ -45,6 +46,7 @@ export function grantsForHumanRole( { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "plugins:manage", scope: null }, ]; case "operator": return [{ permissionKey: "tasks:assign", scope: null }]; diff --git a/server/src/services/plugin-company-enablement.test.ts b/server/src/services/plugin-company-enablement.test.ts new file mode 100644 index 00000000000..008844381d8 --- /dev/null +++ b/server/src/services/plugin-company-enablement.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { + assertCompanyEnablement, + createPluginEventDeliverabilityChecker, + evaluateCompanyEnablement, + pluginCompanyEnablementService, + type PluginEnablementRegistry, +} from "./plugin-company-enablement.js"; + +const pluginUuid = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; + +function manifestWith( + companyEnablement?: { default: "on" | "off"; locked?: boolean }, +): Pick { + return companyEnablement ? { companyEnablement } : {}; +} + +describe("evaluateCompanyEnablement", () => { + it("defaults to enabled when the manifest has no companyEnablement and no row exists", () => { + expect(evaluateCompanyEnablement(undefined, null)).toBe(true); + expect(evaluateCompanyEnablement(null, undefined)).toBe(true); + expect(evaluateCompanyEnablement(manifestWith(), null)).toBe(true); + }); + + it("honors an explicit row over any manifest default", () => { + expect(evaluateCompanyEnablement(manifestWith(), { enabled: false })).toBe(false); + expect(evaluateCompanyEnablement(manifestWith(), { enabled: true })).toBe(true); + expect( + evaluateCompanyEnablement(manifestWith({ default: "off" }), { enabled: true }), + ).toBe(true); + expect( + evaluateCompanyEnablement(manifestWith({ default: "on" }), { enabled: false }), + ).toBe(false); + }); + + it("uses the manifest default when no row exists", () => { + expect(evaluateCompanyEnablement(manifestWith({ default: "on" }), null)).toBe(true); + expect(evaluateCompanyEnablement(manifestWith({ default: "off" }), null)).toBe(false); + }); + + it("treats locked as read-transparent: manifest default unless a row overrides", () => { + // Lock enforcement is write-time (the toggle route 409s non-admins); + // an existing row on a locked plugin is instance-admin-written by + // construction, so the read path honors it. + expect( + evaluateCompanyEnablement(manifestWith({ default: "off", locked: true }), null), + ).toBe(false); + expect( + evaluateCompanyEnablement(manifestWith({ default: "off", locked: true }), { enabled: true }), + ).toBe(true); + expect( + evaluateCompanyEnablement(manifestWith({ default: "on", locked: true }), null), + ).toBe(true); + }); +}); + +describe("assertCompanyEnablement", () => { + it("throws the typed 403 when the effective state is disabled", () => { + let caught: unknown; + try { + assertCompanyEnablement(manifestWith({ default: "off" }), null); + } catch (err) { + caught = err; + } + expect(caught).toMatchObject({ + status: 403, + details: { code: "plugin_not_enabled_for_company" }, + }); + }); + + it("does not throw when the effective state is enabled", () => { + expect(() => assertCompanyEnablement(undefined, null)).not.toThrow(); + }); +}); + +function fakeRegistry(overrides: Partial = {}): PluginEnablementRegistry { + return { + getById: vi.fn(async () => ({ manifestJson: {} as PaperclipPluginManifestV1 })), + getByKey: vi.fn(async () => null), + getCompanySettings: vi.fn(async () => null), + ...overrides, + }; +} + +describe("pluginCompanyEnablementService", () => { + it("resolves the manifest via getById and combines it with the settings row", async () => { + const registry = fakeRegistry({ + getById: vi.fn(async () => ({ + manifestJson: { companyEnablement: { default: "off" } } as PaperclipPluginManifestV1, + })), + getCompanySettings: vi.fn(async () => null), + }); + const service = pluginCompanyEnablementService(registry); + + await expect(service.isPluginEnabledForCompany(pluginUuid, companyId)).resolves.toBe(false); + expect(registry.getById).toHaveBeenCalledWith(pluginUuid); + expect(registry.getCompanySettings).toHaveBeenCalledWith(pluginUuid, companyId); + }); + + it("returns true for a default-on plugin without a row and false with a disabled row", async () => { + const service = pluginCompanyEnablementService(fakeRegistry()); + await expect(service.isPluginEnabledForCompany(pluginUuid, companyId)).resolves.toBe(true); + + const disabled = pluginCompanyEnablementService(fakeRegistry({ + getCompanySettings: vi.fn(async () => ({ enabled: false }) as never), + })); + await expect(disabled.isPluginEnabledForCompany(pluginUuid, companyId)).resolves.toBe(false); + }); + + it("treats an unknown pluginId as disabled (fail closed)", async () => { + const service = pluginCompanyEnablementService(fakeRegistry({ + getById: vi.fn(async () => null), + })); + await expect(service.isPluginEnabledForCompany(pluginUuid, companyId)).resolves.toBe(false); + }); + + it("ensurePluginEnabledForCompany throws the typed 403 when disabled", async () => { + const service = pluginCompanyEnablementService(fakeRegistry({ + getCompanySettings: vi.fn(async () => ({ enabled: false }) as never), + })); + await expect( + service.ensurePluginEnabledForCompany(pluginUuid, companyId), + ).rejects.toMatchObject({ + status: 403, + details: { code: "plugin_not_enabled_for_company" }, + }); + }); + + it("ensurePluginEnabledForCompany resolves when enabled", async () => { + const service = pluginCompanyEnablementService(fakeRegistry()); + await expect( + service.ensurePluginEnabledForCompany(pluginUuid, companyId), + ).resolves.toBeUndefined(); + }); +}); + +describe("createPluginEventDeliverabilityChecker", () => { + const pluginKey = "acme.linear"; + + it("resolves the plugin key via getByKey and evaluates manifest + row", async () => { + const getByKey = vi.fn(async () => ({ + id: pluginUuid, + manifestJson: {} as PaperclipPluginManifestV1, + })); + const getCompanySettings = vi.fn(async () => ({ enabled: true }) as never); + const log = vi.fn(); + const checker = createPluginEventDeliverabilityChecker( + fakeRegistry({ getByKey, getCompanySettings }), + log, + ); + + await expect(checker(pluginKey, companyId)).resolves.toBe(true); + expect(getByKey).toHaveBeenCalledWith(pluginKey); + expect(getCompanySettings).toHaveBeenCalledWith(pluginUuid, companyId); + expect(log).not.toHaveBeenCalled(); + }); + + it("returns false when the row disables the plugin", async () => { + const checker = createPluginEventDeliverabilityChecker( + fakeRegistry({ + getByKey: vi.fn(async () => ({ id: pluginUuid, manifestJson: {} as PaperclipPluginManifestV1 })), + getCompanySettings: vi.fn(async () => ({ enabled: false }) as never), + }), + vi.fn(), + ); + await expect(checker(pluginKey, companyId)).resolves.toBe(false); + }); + + it("returns false for a default-off plugin with no row (manifest-aware)", async () => { + const checker = createPluginEventDeliverabilityChecker( + fakeRegistry({ + getByKey: vi.fn(async () => ({ + id: pluginUuid, + manifestJson: { companyEnablement: { default: "off" } } as PaperclipPluginManifestV1, + })), + getCompanySettings: vi.fn(async () => null), + }), + vi.fn(), + ); + await expect(checker(pluginKey, companyId)).resolves.toBe(false); + }); + + it("fails open and skips the settings lookup when the plugin key is unknown", async () => { + const getCompanySettings = vi.fn(); + const checker = createPluginEventDeliverabilityChecker( + fakeRegistry({ getByKey: vi.fn(async () => null), getCompanySettings }), + vi.fn(), + ); + await expect(checker(pluginKey, companyId)).resolves.toBe(true); + expect(getCompanySettings).not.toHaveBeenCalled(); + }); + + it("fails open and logs when the lookup throws", async () => { + const err = new Error("db exploded"); + const log = vi.fn(); + const checker = createPluginEventDeliverabilityChecker( + fakeRegistry({ getByKey: vi.fn(async () => { throw err; }) }), + log, + ); + await expect(checker(pluginKey, companyId)).resolves.toBe(true); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ err, pluginKey, companyId }), + expect.any(String), + ); + }); +}); diff --git a/server/src/services/plugin-company-enablement.ts b/server/src/services/plugin-company-enablement.ts new file mode 100644 index 00000000000..83336e159a6 --- /dev/null +++ b/server/src/services/plugin-company-enablement.ts @@ -0,0 +1,121 @@ +/** + * Per-company plugin enablement. + * + * Two AND-ed switches make a plugin act for a company: the instance switch + * (`plugins.status === "ready"`, enforced elsewhere) and the company switch + * computed here from the plugin manifest's `companyEnablement` default plus + * the `plugin_company_settings` row: + * + * - no row + no manifest field => enabled (backward compatible) + * - no row + `default: "on"` => enabled + * - no row + `default: "off"` => disabled (opt-in plugins) + * - row => row.enabled wins + * + * `locked: true` never changes the read path: lock enforcement happens at + * write time (the enablement toggle route rejects non-instance-admins with + * 409 `plugin_enablement_locked`), so any existing row on a locked plugin + * was written by an instance admin and is honored here. + * + * @see docs/superpowers/specs/2026-07-18-settings-visibility-and-plugin-enablement-design.md §4 + */ +import type { PaperclipPluginManifestV1, PluginCompanySettings } from "@paperclipai/shared"; +import { forbidden } from "../errors.js"; + +/** Minimal manifest slice the enablement computation needs. */ +export type CompanyEnablementManifest = + Pick | null | undefined; + +/** + * Pure enablement computation: settings row wins; otherwise the manifest + * default; otherwise "on". + */ +export function evaluateCompanyEnablement( + manifest: CompanyEnablementManifest, + settings: Pick | null | undefined, +): boolean { + if (settings) return settings.enabled; + return (manifest?.companyEnablement?.default ?? "on") === "on"; +} + +/** + * Throwing form of {@link evaluateCompanyEnablement} for request-path gates + * that already hold the plugin record and settings row. Fails closed with + * the typed 403 used at every enforcement point. + */ +export function assertCompanyEnablement( + manifest: CompanyEnablementManifest, + settings: Pick | null | undefined, +): void { + if (!evaluateCompanyEnablement(manifest, settings)) { + throw forbidden("Plugin is not enabled for this company", { + code: "plugin_not_enabled_for_company", + }); + } +} + +/** + * Registry surface the enablement service needs. The full + * `pluginRegistryService(db)` object structurally satisfies this. + */ +export interface PluginEnablementRegistry { + getById(pluginId: string): Promise<{ manifestJson: PaperclipPluginManifestV1 | null } | null>; + getByKey(pluginKey: string): Promise<{ id: string; manifestJson: PaperclipPluginManifestV1 | null } | null>; + getCompanySettings(pluginId: string, companyId: string): Promise; +} + +/** + * Registry-backed enablement service. `pluginId` is the plugin's database + * UUID (`plugins.id`), matching `plugin_company_settings.plugin_id`. + */ +export function pluginCompanyEnablementService(registry: PluginEnablementRegistry) { + async function isPluginEnabledForCompany(pluginId: string, companyId: string): Promise { + const [plugin, settings] = await Promise.all([ + registry.getById(pluginId), + registry.getCompanySettings(pluginId, companyId), + ]); + // Unknown plugin: fail closed. Request-path gates should have 404'd + // earlier; anything that reaches this with a bogus id gets a deny. + if (!plugin) return false; + return evaluateCompanyEnablement(plugin.manifestJson, settings); + } + + async function ensurePluginEnabledForCompany(pluginId: string, companyId: string): Promise { + if (!(await isPluginEnabledForCompany(pluginId, companyId))) { + throw forbidden("Plugin is not enabled for this company", { + code: "plugin_not_enabled_for_company", + }); + } + } + + return { isPluginEnabledForCompany, ensurePluginEnabledForCompany }; +} + +/** + * Event-bus deliverability checker. The bus registers subscriptions under + * the manifest `pluginKey` (see plugin-event-bus.ts `forPlugin`), so this + * resolves key -> plugin record before consulting the manifest default and + * `plugin_company_settings` (keyed by the plugin's uuid). + * + * Fails OPEN — an enablement lookup error must never silently drop events — + * and logs so failures stay visible. This is deliberately the opposite of + * the request-path gates, which fail closed. + */ +export function createPluginEventDeliverabilityChecker( + registry: PluginEnablementRegistry, + log: (context: { err: unknown; pluginKey: string; companyId: string }, msg: string) => void, +): (pluginKey: string, companyId: string) => Promise { + return async (pluginKey, companyId) => { + try { + const plugin = await registry.getByKey(pluginKey); + if (!plugin) return true; + const settings = await registry.getCompanySettings(plugin.id, companyId); + return evaluateCompanyEnablement(plugin.manifestJson, settings); + } catch (err) { + log( + { err, pluginKey, companyId }, + "Plugin enablement lookup failed; delivering event (fail open)", + ); + return true; + } + }; +} diff --git a/server/src/services/plugin-event-bus.test.ts b/server/src/services/plugin-event-bus.test.ts new file mode 100644 index 00000000000..59bdca274fb --- /dev/null +++ b/server/src/services/plugin-event-bus.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginEvent } from "@paperclipai/plugin-sdk"; +import { createPluginEventBus } from "./plugin-event-bus.js"; + +/** + * Builds a minimal, well-typed `PluginEvent`. `overrides` lets tests blank + * out `companyId` to simulate an event without company context (the bus + * treats a falsy value as "absent" for gating purposes). + */ +function makeEvent(overrides: Partial = {}): PluginEvent { + return { + eventId: "evt-1", + eventType: "issue.created", + occurredAt: new Date().toISOString(), + companyId: "company-1", + payload: {}, + ...overrides, + } as PluginEvent; +} + +describe("per-company event delivery gate", () => { + function busWithChecker(deliverable: boolean) { + const isPluginDeliverableForCompany = vi.fn(async () => deliverable); + const bus = createPluginEventBus({ isPluginDeliverableForCompany }); + return { bus, isPluginDeliverableForCompany }; + } + + it("skips delivery to a plugin disabled for the event's company", async () => { + const { bus, isPluginDeliverableForCompany } = busWithChecker(false); + const handler = vi.fn(async () => {}); + bus.forPlugin("plugin-a").subscribe("issue.created", handler); + + const result = await bus.emit(makeEvent()); + + expect(handler).not.toHaveBeenCalled(); + expect(isPluginDeliverableForCompany).toHaveBeenCalledWith("plugin-a", "company-1"); + expect(result.errors).toEqual([]); + }); + + it("delivers when the checker allows", async () => { + const { bus } = busWithChecker(true); + const handler = vi.fn(async () => {}); + bus.forPlugin("plugin-a").subscribe("issue.created", handler); + + const result = await bus.emit(makeEvent()); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result.errors).toEqual([]); + }); + + it("does not consult the checker for events without a companyId", async () => { + const { bus, isPluginDeliverableForCompany } = busWithChecker(false); + const handler = vi.fn(async () => {}); + bus.forPlugin("plugin-a").subscribe("activity.logged", handler); + + await bus.emit(makeEvent({ + eventType: "activity.logged", + companyId: undefined as unknown as string, + })); + + expect(isPluginDeliverableForCompany).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("memoizes the check per plugin within one emit and re-checks on the next", async () => { + const { bus, isPluginDeliverableForCompany } = busWithChecker(true); + const handlerA1 = vi.fn(async () => {}); + const handlerA2 = vi.fn(async () => {}); + bus.forPlugin("plugin-a").subscribe("issue.created", handlerA1); + bus.forPlugin("plugin-a").subscribe("issue.created", handlerA2); + + await bus.emit(makeEvent()); + expect(isPluginDeliverableForCompany).toHaveBeenCalledTimes(1); + expect(handlerA1).toHaveBeenCalledTimes(1); + expect(handlerA2).toHaveBeenCalledTimes(1); + + await bus.emit(makeEvent()); + expect(isPluginDeliverableForCompany).toHaveBeenCalledTimes(2); + }); + + it("fails open (delivers) when the checker throws", async () => { + const isPluginDeliverableForCompany = vi.fn(async () => { + throw new Error("enablement lookup failed"); + }); + const bus = createPluginEventBus({ isPluginDeliverableForCompany }); + const handler = vi.fn(async () => {}); + bus.forPlugin("plugin-a").subscribe("issue.created", handler); + + const result = await bus.emit(makeEvent()); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result.errors).toEqual([]); + }); + + it("keeps the existing no-arg call form working unchanged", async () => { + const bus = createPluginEventBus(); + const handler = vi.fn(async () => {}); + bus.forPlugin("plugin-a").subscribe("issue.created", handler); + + const result = await bus.emit(makeEvent()); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result.errors).toEqual([]); + }); +}); diff --git a/server/src/services/plugin-event-bus.ts b/server/src/services/plugin-event-bus.ts index 130db8956aa..ccdc4c84885 100644 --- a/server/src/services/plugin-event-bus.ts +++ b/server/src/services/plugin-event-bus.ts @@ -146,7 +146,7 @@ function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean { * }); * ``` */ -export function createPluginEventBus(): PluginEventBus { +export function createPluginEventBus(options: PluginEventBusOptions = {}): PluginEventBus { // Subscription registry: pluginKey → list of subscriptions const registry = new Map(); @@ -173,6 +173,23 @@ export function createPluginEventBus(): PluginEventBus { const errors: Array<{ pluginId: string; error: unknown }> = []; const promises: Promise[] = []; + // Per-emit, per-plugin memoized enablement check. A fresh cache per call + // to `emit` keeps this cheap (at most one lookup per plugin per event) + // without leaking state across unrelated events. + const deliverableCache = new Map>(); + const isDeliverable = (pluginId: string, companyId: string): Promise => { + if (!options.isPluginDeliverableForCompany) return Promise.resolve(true); + const cached = deliverableCache.get(pluginId); + if (cached) return cached; + const result = options + .isPluginDeliverableForCompany(pluginId, companyId) + // Fail open: an enablement-lookup failure must not silently drop + // events. The checker is responsible for logging its own errors. + .catch(() => true); + deliverableCache.set(pluginId, result); + return result; + }; + for (const [pluginId, subs] of registry) { for (const sub of subs) { if (!matchesPattern(event.eventType, sub.eventPattern)) continue; @@ -186,9 +203,16 @@ export function createPluginEventBus(): PluginEventBus { // exceptions become rejections. Each .catch() swallows the rejection // and records it — the promise always resolves, so Promise.all never rejects. promises.push( - Promise.resolve().then(() => sub.handler(event)).catch((error: unknown) => { - errors.push({ pluginId, error }); - }), + Promise.resolve() + .then(async () => { + if (event.companyId && !(await isDeliverable(pluginId, event.companyId))) { + return; + } + await sub.handler(event); + }) + .catch((error: unknown) => { + errors.push({ pluginId, error }); + }), ); } } @@ -315,6 +339,29 @@ export interface PluginEventBusEmitResult { errors: Array<{ pluginId: string; error: unknown }>; } +/** + * Options for {@link createPluginEventBus}. + */ +export interface PluginEventBusOptions { + /** + * When set, events carrying a (truthy) `companyId` are only delivered to + * a given plugin's subscriptions when this resolves `true` for + * `(pluginKey, companyId)` — i.e. per-company plugin enablement. The bus + * registers subscriptions under the manifest `pluginKey` (see + * `forPlugin`/`subsFor`), so that is what this checker receives — not + * the plugin's database uuid. Events without a `companyId` are always + * delivered and this checker is never consulted for them. + * + * If the checker's promise rejects, delivery fails open (the event is + * still delivered) so an enablement-lookup failure can never silently + * drop events; the checker is responsible for logging its own errors. + */ + isPluginDeliverableForCompany?: ( + pluginKey: string, + companyId: string, + ) => Promise; +} + /** * The full event bus — held by the host process. * diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index f2798cc6343..31165671bfd 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -43,6 +43,7 @@ import { subscribeCompanyLiveEvents } from "./live-events.js"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import path from "node:path"; import { pluginRegistryService } from "./plugin-registry.js"; +import { pluginCompanyEnablementService } from "./plugin-company-enablement.js"; import { pluginStateStore } from "./plugin-state-store.js"; import { pluginDatabaseService } from "./plugin-database.js"; import { pluginManagedAgentService } from "./plugin-managed-agents.js"; @@ -586,11 +587,16 @@ export function buildHostServices( }; /** - * Plugins are instance-wide in the current runtime. Company IDs are still - * required for company-scoped data access, but there is no per-company - * availability gate to enforce here. + * Per-company availability gate: companies can disable an installed + * plugin via plugin_company_settings (manifest `companyEnablement` + * default applies when no row exists). Every company-scoped host + * operation awaits this before touching company data; a disabled + * plugin gets the typed 403 `plugin_not_enabled_for_company`. */ - const ensurePluginAvailableForCompany = async (_companyId: string) => {}; + const companyEnablement = pluginCompanyEnablementService(registry); + const ensurePluginAvailableForCompany = async (companyId: string) => { + await companyEnablement.ensurePluginEnabledForCompany(pluginId, companyId); + }; const getLocalFolderDeclaration = (folderKey: string) => requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b55bf7b54d1..e3e571e5516 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -61,6 +61,7 @@ import { AppNotConnected } from "./pages/apps/AppNotConnected"; import { GatewaysList } from "./pages/apps/gateways/GatewaysList"; import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail"; import { CompanyInvites } from "./pages/CompanyInvites"; +import { CompanyPlugins } from "./pages/CompanyPlugins"; import { CompanySkills } from "./pages/CompanySkills"; import { SkillStudio } from "./pages/SkillStudio"; import { Secrets } from "./pages/Secrets"; @@ -122,6 +123,14 @@ function boardRoutes() { } /> + + + + } + /> } /> } /> launcher.placementZone === "toolbarButton"), * ); * ``` + * + * When companyId is provided, the server also filters out contributions + * from plugins disabled for that company (and asserts company access). */ - listUiContributions: () => - api.get("/plugins/ui-contributions"), + listUiContributions: (companyId?: string) => + api.get( + companyId + ? `/plugins/ui-contributions?companyId=${encodeURIComponent(companyId)}` + : "/plugins/ui-contributions", + ), // =========================================================================== // Plugin configuration endpoints @@ -430,6 +463,29 @@ export const pluginsApi = { input, ), + // =========================================================================== + // Company plugin catalog endpoints + // =========================================================================== + + /** + * List the company-scoped plugin catalog: every `ready`, catalog-eligible + * plugin with its effective enablement state, lock state, and (when + * contributed) company settings route. Used by the CompanyPlugins page. + */ + listCompanyPluginCatalog: (companyId: string) => + api.get(`/plugins/companies/${encodeURIComponent(companyId)}/catalog`), + + /** + * Enable or disable a plugin for a specific company. Locked plugins + * reject non-instance-admin toggles with 409 `plugin_enablement_locked`. + * Returns the updated catalog item. + */ + setCompanyPluginEnabled: (pluginId: string, companyId: string, enabled: boolean) => + api.put( + `/plugins/${encodeURIComponent(pluginId)}/companies/${encodeURIComponent(companyId)}/enablement`, + { enabled }, + ), + // =========================================================================== // Bridge proxy endpoints — used by the plugin UI bridge runtime // =========================================================================== diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index d7e0a3cf8d9..0e59165a002 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -188,6 +188,13 @@ describe("CompanySettingsSidebar", () => { end: true, }), ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/plugins", + label: "Plugins", + end: true, + }), + ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ to: "/company/settings/secrets", @@ -423,6 +430,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Members"); expect(container.textContent).not.toContain("Invites"); expect(container.textContent).not.toContain("Secrets"); + expect(container.textContent).not.toContain("Plugins"); expect(container.textContent).not.toContain("Instance settings"); expect(sidebarNavItemMock).not.toHaveBeenCalledWith( expect.objectContaining({ to: "/company/settings/instance/general" }), @@ -449,6 +457,9 @@ describe("CompanySettingsSidebar", () => { await flushReact(); expect(container.textContent).toContain("Invites"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "/company/settings/plugins", label: "Plugins", end: true }), + ); expect(container.textContent).toContain("Secrets"); expect(container.textContent).toContain("Instance settings"); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 6d4c3c7d658..af7003c9159 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -137,6 +137,9 @@ export function CompanySettingsSidebar() { {exposedSurfaces.has("company.invites") ? ( ) : null} + {exposedSurfaces.has("company.plugins") ? ( + + ) : null} {exposedSurfaces.has("company.secrets") ? ( ) : null} diff --git a/ui/src/components/access/CompanySettingsNav.test.tsx b/ui/src/components/access/CompanySettingsNav.test.tsx index 9c1164e448e..d25b63a0695 100644 --- a/ui/src/components/access/CompanySettingsNav.test.tsx +++ b/ui/src/components/access/CompanySettingsNav.test.tsx @@ -98,6 +98,7 @@ describe("CompanySettingsNav", () => { expect(getCompanySettingsTab("/company/settings/access")).toBe("members"); expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members"); expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites"); + expect(getCompanySettingsTab("/company/settings/plugins")).toBe("plugins"); expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets"); expect(getCompanySettingsTab("/company/settings/instance/profile")).toBe("instance-profile"); expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("instance-general"); @@ -131,6 +132,7 @@ describe("CompanySettingsNav", () => { { value: "general", label: "General" }, { value: "members", label: "Members" }, { value: "invites", label: "Invites" }, + { value: "plugins", label: "Plugins" }, { value: "secrets", label: "Secrets" }, { value: "instance-profile", label: "Instance profile" }, { value: "instance-general", label: "Instance general" }, diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index 17826a4f392..82d8ae66bf3 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -10,6 +10,7 @@ const items = [ { value: "cloud-upstream", label: "Cloud upstream", href: "/company/settings/cloud-upstream" }, { value: "members", label: "Members", href: "/company/settings/members" }, { value: "invites", label: "Invites", href: "/company/settings/invites" }, + { value: "plugins", label: "Plugins", href: "/company/settings/plugins" }, { value: "secrets", label: "Secrets", href: "/company/settings/secrets" }, { value: "instance-profile", label: "Instance profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` }, { value: "instance-general", label: "Instance general", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/general` }, @@ -72,6 +73,10 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab { return "invites"; } + if (pathname.includes("/company/settings/plugins")) { + return "plugins"; + } + if (pathname.includes("/company/settings/secrets")) { return "secrets"; } @@ -95,6 +100,7 @@ export function CompanySettingsNav() { if (item.value === "cloud-upstream") return cloudSyncEnabled; if (item.value === "members") return exposedSurfaces.has("company.members"); if (item.value === "invites") return exposedSurfaces.has("company.invites"); + if (item.value === "plugins") return exposedSurfaces.has("company.plugins"); if (item.value === "secrets") return exposedSurfaces.has("company.secrets"); if (item.value === "instance-profile") return true; // per-user, always visible return isInstanceAdmin; // all remaining instance-* tabs diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index a4bda314e2b..5187bac0cdb 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -385,6 +385,8 @@ export const queryKeys = { config: (pluginId: string, companyId: string) => ["plugins", pluginId, "companies", companyId, "config"] as const, localFolders: (pluginId: string, companyId: string) => ["plugins", pluginId, "companies", companyId, "local-folders"] as const, + companyCatalog: (companyId: string) => + ["plugins", "companies", companyId, "catalog"] as const, dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const, logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const, }, diff --git a/ui/src/pages/CompanyPlugins.test.tsx b/ui/src/pages/CompanyPlugins.test.tsx new file mode 100644 index 00000000000..4fad7b1f0e1 --- /dev/null +++ b/ui/src/pages/CompanyPlugins.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanyPlugins } from "./CompanyPlugins"; +import { ApiError } from "@/api/client"; +import type { CompanyPluginCatalogItem } from "@/api/plugins"; +import { queryKeys } from "@/lib/queryKeys"; + +const listCompanyPluginCatalogMock = vi.hoisted(() => vi.fn()); +const setCompanyPluginEnabledMock = vi.hoisted(() => vi.fn()); +const pushToastMock = vi.hoisted(() => vi.fn()); +const setBreadcrumbsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/plugins", () => ({ + pluginsApi: { + listCompanyPluginCatalog: (companyId: string) => listCompanyPluginCatalogMock(companyId), + setCompanyPluginEnabled: (pluginId: string, companyId: string, enabled: boolean) => + setCompanyPluginEnabledMock(pluginId, companyId, enabled), + }, +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" }, + }), +})); + +vi.mock("@/context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }), +})); + +vi.mock("@/context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: pushToastMock }), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, + Navigate: ({ to }: { to: string }) =>
, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +async function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + return { container, root, queryClient }; +} + +function catalogItem(overrides: Partial = {}): CompanyPluginCatalogItem { + return { + pluginId: "plugin-1", + pluginKey: "linear-sync", + displayName: "Linear Sync", + version: "1.2.0", + description: "Sync issues with Linear.", + capabilities: ["issues.read"], + enabled: true, + locked: false, + defaultEnabled: true, + hasCompanySettingsPage: false, + settingsRoutePath: null, + ...overrides, + }; +} + +describe("CompanyPlugins", () => { + afterEach(() => { + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + beforeEach(() => { + setCompanyPluginEnabledMock.mockResolvedValue(catalogItem()); + }); + + it("renders one row per catalog item with displayName and version", async () => { + listCompanyPluginCatalogMock.mockResolvedValue([ + catalogItem({ pluginId: "plugin-1", displayName: "Linear Sync", version: "1.2.0" }), + catalogItem({ pluginId: "plugin-2", displayName: "Slack Bridge", version: "0.4.1" }), + ]); + + const { container, root } = await renderPage(); + + expect(listCompanyPluginCatalogMock).toHaveBeenCalledWith("company-1"); + expect(container.textContent).toContain("Linear Sync"); + expect(container.textContent).toContain("1.2.0"); + expect(container.textContent).toContain("Slack Bridge"); + expect(container.textContent).toContain("0.4.1"); + // Capability summary (spec §4.5) + expect(container.textContent).toContain("issues.read"); + + await act(async () => { + root.unmount(); + }); + }); + + it("toggles a plugin's enablement and refreshes catalog + ui contributions", async () => { + const disabled = catalogItem({ pluginId: "plugin-1", displayName: "Linear Sync", enabled: false }); + listCompanyPluginCatalogMock.mockResolvedValueOnce([disabled]); + listCompanyPluginCatalogMock.mockResolvedValueOnce([{ ...disabled, enabled: true }]); + setCompanyPluginEnabledMock.mockResolvedValue({ ...disabled, enabled: true }); + + const { container, root, queryClient } = await renderPage(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const enableButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Enable", + ); + expect(enableButton).toBeTruthy(); + + await act(async () => { + enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + expect(setCompanyPluginEnabledMock).toHaveBeenCalledWith("plugin-1", "company-1", true); + expect(listCompanyPluginCatalogMock).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain("Disable"); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: queryKeys.plugins.companyCatalog("company-1") }), + ); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: queryKeys.plugins.uiContributions }), + ); + + await act(async () => { + root.unmount(); + }); + }); + + it("renders locked plugins as non-interactive, managed-by-instance rows", async () => { + listCompanyPluginCatalogMock.mockResolvedValue([ + catalogItem({ + pluginId: "plugin-billing", + displayName: "Billing", + enabled: true, + locked: true, + }), + ]); + + const { container, root } = await renderPage(); + + expect(container.textContent).toContain("Managed by instance"); + const buttons = Array.from(container.querySelectorAll("button")).map( + (button) => button.textContent?.trim(), + ); + expect(buttons).not.toContain("Disable"); + expect(buttons).not.toContain("Enable"); + + await act(async () => { + root.unmount(); + }); + }); + + it("renders a Settings link only for enabled plugins with a settingsRoutePath", async () => { + listCompanyPluginCatalogMock.mockResolvedValue([ + catalogItem({ + pluginId: "plugin-1", + displayName: "Linear Sync", + enabled: true, + hasCompanySettingsPage: true, + settingsRoutePath: "linear-sync", + }), + catalogItem({ + pluginId: "plugin-2", + displayName: "Slack Bridge", + enabled: false, + hasCompanySettingsPage: true, + settingsRoutePath: "slack-bridge", + }), + catalogItem({ + pluginId: "plugin-3", + displayName: "No Settings Plugin", + enabled: true, + }), + ]); + + const { container, root } = await renderPage(); + + const links = Array.from(container.querySelectorAll("a")); + expect(links.some((link) => link.getAttribute("href") === "/company/settings/linear-sync")).toBe(true); + expect(links.some((link) => link.getAttribute("href") === "/company/settings/slack-bridge")).toBe(false); + expect(links.length).toBe(1); + + await act(async () => { + root.unmount(); + }); + }); + + it("treats a 403 catalog response as a navigation miss (redirect to settings root)", async () => { + listCompanyPluginCatalogMock.mockRejectedValue( + new ApiError("Forbidden", 403, { code: "surface_not_exposed" }), + ); + + const { container, root } = await renderPage(); + + const navigate = container.querySelector('[data-testid="navigate"]'); + expect(navigate).not.toBeNull(); + expect(navigate?.getAttribute("data-to")).toBe("/company/settings"); + + await act(async () => { + root.unmount(); + }); + }); + + it("renders an empty state mentioning that instance admins install plugins", async () => { + listCompanyPluginCatalogMock.mockResolvedValue([]); + + const { container, root } = await renderPage(); + + expect(container.textContent).toMatch(/instance admin/i); + expect(container.textContent).toMatch(/install/i); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/CompanyPlugins.tsx b/ui/src/pages/CompanyPlugins.tsx new file mode 100644 index 00000000000..2cc9512601c --- /dev/null +++ b/ui/src/pages/CompanyPlugins.tsx @@ -0,0 +1,186 @@ +import { useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Lock, Puzzle, Settings as SettingsIcon } from "lucide-react"; +import { ApiError } from "@/api/client"; +import { pluginsApi, type CompanyPluginCatalogItem } from "@/api/plugins"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useCompany } from "@/context/CompanyContext"; +import { useToastActions } from "@/context/ToastContext"; +import { Link, Navigate } from "@/lib/router"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; + +/** + * Company-settings "Plugins" page (settings surface `company.plugins`). + * + * Lists every `ready`, catalog-eligible plugin with its per-company + * enablement state and lets holders of `plugins:manage` (company + * owners/admins implicitly) turn plugins on or off for this company. + * Plugins are installed/removed by instance admins (see PluginManager); + * this page only toggles the company-scoped switch. + * + * Locked plugins (`manifest.companyEnablement.locked`) render as + * non-interactive "Managed by instance" rows. + * + * A 403 from the catalog (hidden surface, revoked access) is a navigation + * miss, not a crash: redirect to the company settings root. + * + * @see server/src/routes/plugins.ts — `GET /plugins/companies/:companyId/catalog` + * and `PUT /plugins/:pluginId/companies/:companyId/enablement`. + */ +export function CompanyPlugins() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToastActions(); + const queryClient = useQueryClient(); + + useEffect(() => { + setBreadcrumbs([ + { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, + { label: "Settings", href: "/company/settings" }, + { label: "Plugins" }, + ]); + }, [selectedCompany?.name, setBreadcrumbs]); + + const catalogQueryKey = queryKeys.plugins.companyCatalog(selectedCompanyId ?? ""); + const { + data: catalog, + isLoading, + error, + } = useQuery({ + queryKey: catalogQueryKey, + queryFn: () => pluginsApi.listCompanyPluginCatalog(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + const toggleMutation = useMutation({ + mutationFn: (item: CompanyPluginCatalogItem) => + pluginsApi.setCompanyPluginEnabled(item.pluginId, selectedCompanyId!, !item.enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: catalogQueryKey }); + // Prefix match: also covers ui-contributions queries suffixed with a + // companyId (see ui/src/plugins/slots.tsx). Without this, toggling a + // plugin off leaves its UI contributions visible until react-query's + // next background refetch. + queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions }); + }, + onError: (err: Error) => { + pushToast({ title: "Failed to update plugin", body: err.message, tone: "error" }); + }, + }); + + if (!selectedCompanyId) { + return
Select a company to manage plugins.
; + } + + if (isLoading) { + return
Loading plugins…
; + } + + if (error) { + // 403 (hidden surface / revoked access) is a navigation miss, not a crash. + if (error instanceof ApiError && error.status === 403) { + return ; + } + return
Failed to load plugins.
; + } + + const items = catalog ?? []; + + return ( +
+
+
+ +

Plugins

+
+

+ Turn installed plugins on or off for this company. Disabling a plugin here + hides its contributions from this company without uninstalling it. +

+
+ + {items.length === 0 ? ( + + + +

No plugins installed

+

+ Instance admins install plugins from instance settings. Once a plugin + is installed, it will appear here so you can enable it for this company. +

+
+
+ ) : ( + +
    + {items.map((item) => { + const pending = + toggleMutation.isPending && toggleMutation.variables?.pluginId === item.pluginId; + return ( +
  • +
    +
    +
    + {item.displayName} + v{item.version} + + {item.enabled ? "Enabled" : "Disabled"} + + {item.locked ? ( + + + Managed by instance + + ) : null} +
    + {item.description ? ( +

    + {item.description} +

    + ) : null} + {item.capabilities.length > 0 ? ( +

    + Capabilities: {item.capabilities.join(", ")} +

    + ) : null} +
    +
    + {item.enabled && item.settingsRoutePath ? ( + + ) : null} + {item.locked ? null : ( + + )} +
    +
    +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/ui/src/pages/PluginPage.test.tsx b/ui/src/pages/PluginPage.test.tsx index 89775e237a3..5d63de1e3da 100644 --- a/ui/src/pages/PluginPage.test.tsx +++ b/ui/src/pages/PluginPage.test.tsx @@ -135,6 +135,7 @@ describe("PluginPage", () => { ]); expect(container.textContent).toContain("Back"); expect(container.querySelector('a[href="/PAP/dashboard"]')).not.toBeNull(); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledWith("company-1"); await act(async () => { root.unmount(); diff --git a/ui/src/pages/PluginPage.tsx b/ui/src/pages/PluginPage.tsx index f22ee4a930e..fc504e3862f 100644 --- a/ui/src/pages/PluginPage.tsx +++ b/ui/src/pages/PluginPage.tsx @@ -52,8 +52,8 @@ export function PluginPage() { ); const { data: contributions } = useQuery({ - queryKey: queryKeys.plugins.uiContributions, - queryFn: () => pluginsApi.listUiContributions(), + queryKey: [...queryKeys.plugins.uiContributions, resolvedCompanyId ?? null], + queryFn: () => pluginsApi.listUiContributions(resolvedCompanyId ?? undefined), enabled: !!resolvedCompanyId && (!!pluginId || !!pluginRoutePath), }); diff --git a/ui/src/plugins/launchers.test.ts b/ui/src/plugins/launchers.test.ts new file mode 100644 index 00000000000..2db2776e6b8 --- /dev/null +++ b/ui/src/plugins/launchers.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { createElement } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePluginLaunchers } from "./launchers"; + +const mockPluginsApi = vi.hoisted(() => ({ + listUiContributions: vi.fn(), + bridgePerformAction: vi.fn(), +})); + +vi.mock("../api/plugins", () => ({ pluginsApi: mockPluginsApi })); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("usePluginLaunchers company filtering", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let captured: any = null; + + function Harness({ companyId }: { companyId?: string | null }) { + captured = usePluginLaunchers({ placementZones: ["toolbarButton"], companyId }); + return null; + } + + async function renderHook(companyId?: string | null) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness, { companyId }), + ), + ); + }); + await flushReact(); + return () => { + root.unmount(); + container.remove(); + }; + } + + beforeEach(() => { + captured = null; + mockPluginsApi.listUiContributions.mockReset(); + mockPluginsApi.listUiContributions.mockResolvedValue([]); + }); + + it("fetches without a companyId when the filter omits it", async () => { + const cleanup = await renderHook(undefined); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledWith(undefined); + cleanup(); + }); + + it("fetches with the companyId when the filter provides it", async () => { + const cleanup = await renderHook("company-1"); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledWith("company-1"); + cleanup(); + }); + + it("keys the query on companyId so switching companies re-fetches", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness, { companyId: "company-1" }), + ), + ); + }); + await flushReact(); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledTimes(1); + expect(mockPluginsApi.listUiContributions).toHaveBeenLastCalledWith("company-1"); + + await act(async () => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness, { companyId: "company-2" }), + ), + ); + }); + await flushReact(); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledTimes(2); + expect(mockPluginsApi.listUiContributions).toHaveBeenLastCalledWith("company-2"); + + root.unmount(); + container.remove(); + }); + + it("excludes launchers from a plugin's contribution when it is filtered out for the company (disabled plugin never reaches the client)", async () => { + // Simulates the server-side per-company enablement filter: a disabled + // plugin's contribution (and thus its launchers) never appears in the + // /api/plugins/ui-contributions response for that company. + mockPluginsApi.listUiContributions.mockResolvedValue([]); + const cleanup = await renderHook("company-1"); + + expect(captured.launchers).toEqual([]); + cleanup(); + }); +}); diff --git a/ui/src/plugins/launchers.tsx b/ui/src/plugins/launchers.tsx index 4dfc447a5d1..5994dbfaeb8 100644 --- a/ui/src/plugins/launchers.tsx +++ b/ui/src/plugins/launchers.tsx @@ -291,8 +291,8 @@ export function usePluginLaunchers( ): UsePluginLaunchersResult { const queryEnabled = filters.enabled ?? true; const { data, isLoading, error } = useQuery({ - queryKey: queryKeys.plugins.uiContributions, - queryFn: () => pluginsApi.listUiContributions(), + queryKey: [...queryKeys.plugins.uiContributions, filters.companyId ?? null], + queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined), enabled: queryEnabled, }); diff --git a/ui/src/plugins/slots.test.ts b/ui/src/plugins/slots.test.ts index 5fb7b147a59..74692a997af 100644 --- a/ui/src/plugins/slots.test.ts +++ b/ui/src/plugins/slots.test.ts @@ -3,15 +3,41 @@ import { createElement } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PluginSlotMount, _collectRegisterableExportNamesForTests, _resetPluginModuleLoader, registerPluginWebComponent, + usePluginSlots, type ResolvedPluginSlot, } from "./slots"; +const mockPluginsApi = vi.hoisted(() => ({ + listUiContributions: vi.fn(), +})); + +vi.mock("../api/plugins", () => ({ pluginsApi: mockPluginsApi })); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + let roots: Root[] = []; afterEach(() => { @@ -85,3 +111,88 @@ describe("plugin slot export registration", () => { expect(container.querySelector("paperclip-test-sidebar")).not.toBeNull(); }); }); + +describe("usePluginSlots company filtering", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let captured: any = null; + + function Harness({ companyId }: { companyId?: string | null }) { + captured = usePluginSlots({ slotTypes: ["toolbarButton"], companyId }); + return null; + } + + async function renderHook(companyId?: string | null) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness, { companyId }), + ), + ); + }); + await flushReact(); + return () => { + root.unmount(); + container.remove(); + }; + } + + beforeEach(() => { + captured = null; + mockPluginsApi.listUiContributions.mockReset(); + mockPluginsApi.listUiContributions.mockResolvedValue([]); + }); + + it("fetches without a companyId when the filter omits it", async () => { + const cleanup = await renderHook(undefined); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledWith(undefined); + cleanup(); + }); + + it("fetches with the companyId when the filter provides it", async () => { + const cleanup = await renderHook("company-1"); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledWith("company-1"); + cleanup(); + }); + + it("keys the query on companyId so switching companies re-fetches", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness, { companyId: "company-1" }), + ), + ); + }); + await flushReact(); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledTimes(1); + expect(mockPluginsApi.listUiContributions).toHaveBeenLastCalledWith("company-1"); + + await act(async () => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness, { companyId: "company-2" }), + ), + ); + }); + await flushReact(); + expect(mockPluginsApi.listUiContributions).toHaveBeenCalledTimes(2); + expect(mockPluginsApi.listUiContributions).toHaveBeenLastCalledWith("company-2"); + + root.unmount(); + container.remove(); + }); +}); diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index 82cbeb2c0a7..dab7a588c7d 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -641,8 +641,8 @@ function usePluginModuleLoader(contributions: PluginUiContribution[] | undefined export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult { const queryEnabled = filters.enabled ?? true; const { data, isLoading: isQueryLoading, error } = useQuery({ - queryKey: queryKeys.plugins.uiContributions, - queryFn: () => pluginsApi.listUiContributions(), + queryKey: [...queryKeys.plugins.uiContributions, filters.companyId ?? null], + queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined), enabled: queryEnabled, });