From 12ccfc2c9a976072f6545c426eb86685df058a83 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 16:58:29 -0500 Subject: [PATCH] Simplify plugin runtime and cleanup lifecycle --- .../plugin-file-browser-example/src/worker.ts | 6 +- packages/plugins/sdk/README.md | 2 +- packages/shared/src/index.ts | 8 - packages/shared/src/types/index.ts | 2 - packages/shared/src/types/plugin.ts | 61 ---- packages/shared/src/validators/index.ts | 6 - packages/shared/src/validators/plugin.ts | 42 --- server/src/app.ts | 9 +- server/src/routes/plugins.ts | 214 +------------ server/src/services/plugin-event-bus.ts | 105 +------ server/src/services/plugin-host-services.ts | 24 +- server/src/services/plugin-lifecycle.ts | 32 +- server/src/services/plugin-loader.ts | 68 ++++- server/src/services/plugin-registry.ts | 283 +----------------- server/src/services/plugin-secrets-handler.ts | 13 - ui/src/api/plugins.ts | 56 +--- ui/src/lib/queryKeys.ts | 8 +- ui/src/pages/PluginManager.tsx | 7 +- ui/src/pages/PluginPage.tsx | 4 +- ui/src/plugins/launchers.tsx | 4 +- ui/src/plugins/slots.tsx | 4 +- 21 files changed, 120 insertions(+), 838 deletions(-) diff --git a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts index 1c39af75..a1689834 100644 --- a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts +++ b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts @@ -65,10 +65,10 @@ const plugin = definePlugin({ async setup(ctx) { ctx.logger.info(`${PLUGIN_NAME} plugin setup`); - // Expose the current plugin config so UI components can read the - // commentAnnotationMode setting and hide themselves when disabled. + // Expose the current plugin config so UI components can read operator + // settings from the canonical instance config store. ctx.data.register("plugin-config", async () => { - const config = await ctx.state.get({ scopeKind: "instance", stateKey: "config" }) as Record | null; + const config = await ctx.config.get(); return { showFilesInSidebar: config?.showFilesInSidebar === true, commentAnnotationMode: config?.commentAnnotationMode ?? "both", diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 66cdebcb..f7dc76b6 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -220,7 +220,7 @@ The same set of values is used as **slot types** (where a component mounts) and #### `page` -A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-scoped). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability. +A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-context route). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability. #### `sidebar` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 99315943..b712c3e5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -177,8 +177,6 @@ export type { PluginRecord, PluginStateRecord, PluginConfig, - PluginCompanySettings, - CompanyPluginAvailability, PluginEntityRecord, PluginEntityQuery, PluginJobRecord, @@ -305,9 +303,6 @@ export { installPluginSchema, upsertPluginConfigSchema, patchPluginConfigSchema, - upsertPluginCompanySettingsSchema, - updateCompanyPluginAvailabilitySchema, - listCompanyPluginAvailabilitySchema, updatePluginStatusSchema, uninstallPluginSchema, pluginStateScopeKeySchema, @@ -324,9 +319,6 @@ export { type InstallPlugin, type UpsertPluginConfig, type PatchPluginConfig, - type UpsertPluginCompanySettings, - type UpdateCompanyPluginAvailability, - type ListCompanyPluginAvailability, type UpdatePluginStatus, type UninstallPlugin, type PluginStateScopeKey, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7dfb0a33..bd66a45a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -95,8 +95,6 @@ export type { PluginRecord, PluginStateRecord, PluginConfig, - PluginCompanySettings, - CompanyPluginAvailability, PluginEntityRecord, PluginEntityQuery, PluginJobRecord, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 6254a7bb..1db68a07 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -343,67 +343,6 @@ export interface PluginConfig { updatedAt: Date; } -// --------------------------------------------------------------------------- -// Company Plugin Availability / Settings -// --------------------------------------------------------------------------- - -/** - * Domain type for a plugin's company-scoped settings row as persisted in the - * `plugin_company_settings` table. - * - * This is separate from instance-wide `PluginConfig`: the plugin remains - * installed globally, while each company can store its own plugin settings and - * availability state independently. - */ -export interface PluginCompanySettings { - /** UUID primary key. */ - id: string; - /** FK to `companies.id`. */ - companyId: string; - /** FK to `plugins.id`. */ - pluginId: string; - /** Explicit availability override for this company/plugin pair. */ - enabled: boolean; - /** Company-scoped plugin settings payload. */ - settingsJson: Record; - /** Most recent company-scoped validation or availability error, if any. */ - lastError: string | null; - /** Timestamp when the settings row was created. */ - createdAt: Date; - /** Timestamp of the most recent settings update. */ - updatedAt: Date; -} - -/** - * API response shape describing whether a plugin is available to a specific - * company and, when present, the company-scoped settings row backing that - * availability. - */ -export interface CompanyPluginAvailability { - companyId: string; - pluginId: string; - /** Stable manifest/plugin key for display and route generation. */ - pluginKey: string; - /** Human-readable plugin name. */ - pluginDisplayName: string; - /** Current instance-wide plugin lifecycle status. */ - pluginStatus: PluginStatus; - /** - * Whether the plugin is currently available to the company. - * When no `plugin_company_settings` row exists yet, the plugin is enabled - * by default for the company. - */ - available: boolean; - /** Company-scoped settings, defaulting to an empty object when unavailable. */ - settingsJson: Record; - /** Most recent company-scoped error, if any. */ - lastError: string | null; - /** Present when availability is backed by a persisted settings row. */ - createdAt: Date | null; - /** Present when availability is backed by a persisted settings row. */ - updatedAt: Date | null; -} - /** * Query filter for `ctx.entities.list`. */ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 687dd1ad..8b217c09 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -151,9 +151,6 @@ export { installPluginSchema, upsertPluginConfigSchema, patchPluginConfigSchema, - upsertPluginCompanySettingsSchema, - updateCompanyPluginAvailabilitySchema, - listCompanyPluginAvailabilitySchema, updatePluginStatusSchema, uninstallPluginSchema, pluginStateScopeKeySchema, @@ -170,9 +167,6 @@ export { type InstallPlugin, type UpsertPluginConfig, type PatchPluginConfig, - type UpsertPluginCompanySettings, - type UpdateCompanyPluginAvailability, - type ListCompanyPluginAvailability, type UpdatePluginStatus, type UninstallPlugin, type PluginStateScopeKey, diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index be9b2d5b..149c686a 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -577,48 +577,6 @@ export const patchPluginConfigSchema = z.object({ export type PatchPluginConfig = z.infer; -// --------------------------------------------------------------------------- -// Company plugin availability / settings schemas -// --------------------------------------------------------------------------- - -/** - * Schema for creating or replacing company-scoped plugin settings. - * - * Company-specific settings are stored separately from instance-level - * `plugin_config`, allowing the host to expose a company availability toggle - * without changing the global install state of the plugin. - */ -export const upsertPluginCompanySettingsSchema = z.object({ - settingsJson: z.record(z.unknown()).optional().default({}), - lastError: z.string().nullable().optional(), -}); - -export type UpsertPluginCompanySettings = z.infer; - -/** - * Schema for mutating a plugin's availability for a specific company. - * - * `available=false` lets callers disable access without uninstalling the - * plugin globally. Optional `settingsJson` supports carrying company-specific - * configuration alongside the availability update. - */ -export const updateCompanyPluginAvailabilitySchema = z.object({ - available: z.boolean(), - settingsJson: z.record(z.unknown()).optional(), - lastError: z.string().nullable().optional(), -}); - -export type UpdateCompanyPluginAvailability = z.infer; - -/** - * Query schema for company plugin availability list endpoints. - */ -export const listCompanyPluginAvailabilitySchema = z.object({ - available: z.boolean().optional(), -}); - -export type ListCompanyPluginAvailability = z.infer; - // --------------------------------------------------------------------------- // Plugin status update // --------------------------------------------------------------------------- diff --git a/server/src/app.ts b/server/src/app.ts index 52e06782..8dfcec7c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -140,14 +140,7 @@ export async function createApp( const hostServicesDisposers = new Map void>(); const workerManager = createPluginWorkerManager(); const pluginRegistry = pluginRegistryService(db); - const eventBus = createPluginEventBus({ - async isPluginEnabledForCompany(pluginKey, companyId) { - const plugin = await pluginRegistry.getByKey(pluginKey); - if (!plugin) return false; - const availability = await pluginRegistry.getCompanyAvailability(companyId, plugin.id); - return availability?.available ?? true; - }, - }); + const eventBus = createPluginEventBus(); const jobStore = pluginJobStore(db); const lifecycle = pluginLifecycleManager(db, { workerManager }); const scheduler = createPluginJobScheduler({ diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 2d6382a9..02377412 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -22,7 +22,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { Router } from "express"; -import type { Request, Response } from "express"; +import type { Request } from "express"; import { and, desc, eq, gte } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db"; @@ -31,11 +31,9 @@ import type { PaperclipPluginManifestV1, PluginBridgeErrorCode, PluginLauncherRenderContextSnapshot, - UpdateCompanyPluginAvailability, } from "@paperclipai/shared"; import { PLUGIN_STATUSES, - updateCompanyPluginAvailabilitySchema, } from "@paperclipai/shared"; import { pluginRegistryService } from "../services/plugin-registry.js"; import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; @@ -186,15 +184,6 @@ async function resolvePlugin( return registry.getByKey(pluginId); } -async function isPluginAvailableForCompany( - registry: ReturnType, - companyId: string, - pluginId: string, -): Promise { - const availability = await registry.getCompanyAvailability(companyId, pluginId); - return availability?.available === true; -} - /** * Optional dependencies for plugin job scheduling routes. * @@ -284,9 +273,6 @@ interface PluginToolExecuteRequest { * | GET | /plugins/:pluginId/config | Get current plugin config | * | POST | /plugins/:pluginId/config | Save (upsert) plugin config | * | POST | /plugins/:pluginId/config/test | Test config via validateConfig RPC | - * | GET | /companies/:companyId/plugins | List company-scoped plugin availability | - * | GET | /companies/:companyId/plugins/:pluginId | Get company-scoped plugin availability | - * | PUT | /companies/:companyId/plugins/:pluginId | Save company-scoped plugin availability/settings | * | POST | /plugins/:pluginId/bridge/data | Proxy getData to plugin worker | * | POST | /plugins/:pluginId/bridge/action | Proxy performAction to plugin worker | * | POST | /plugins/:pluginId/data/:key | Proxy getData to plugin worker (key in URL) | @@ -420,10 +406,6 @@ export function pluginRoutes( * - Slots are extracted from manifest.ui.slots * - Launchers are aggregated from legacy manifest.launchers and manifest.ui.launchers * - * Query params: - * - `companyId` (optional): filters out plugins disabled for the target - * company and applies `assertCompanyAccess` - * * Example response: * ```json * [ @@ -451,21 +433,9 @@ export function pluginRoutes( */ router.get("/plugins/ui-contributions", async (req, res) => { assertBoard(req); - const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined; - if (companyId) { - assertCompanyAccess(req, companyId); - } - const plugins = await registry.listByStatus("ready"); - const availablePluginIds = companyId - ? new Set( - (await registry.listCompanyAvailability(companyId, { available: true })) - .map((entry) => entry.pluginId), - ) - : null; const contributions: PluginUiContribution[] = plugins - .filter((plugin) => availablePluginIds === null || availablePluginIds.has(plugin.id)) .map((plugin) => { // Safety check: manifestJson should always exist for ready plugins, but guard against null const manifest = plugin.manifestJson; @@ -489,121 +459,6 @@ export function pluginRoutes( res.json(contributions); }); - // =========================================================================== - // Company-scoped plugin settings / availability routes - // =========================================================================== - - /** - * GET /api/companies/:companyId/plugins - * - * List every installed plugin as it applies to a specific company. Plugins - * are enabled by default; rows in `plugin_company_settings` only store - * explicit overrides and any company-scoped settings payload. - * - * Query params: - * - `available` (optional): `true` or `false` filter - */ - router.get("/companies/:companyId/plugins", async (req, res) => { - assertBoard(req); - const { companyId } = req.params; - assertCompanyAccess(req, companyId); - - let available: boolean | undefined; - const rawAvailable = req.query.available; - if (rawAvailable !== undefined) { - if (rawAvailable === "true") available = true; - else if (rawAvailable === "false") available = false; - else { - res.status(400).json({ error: '"available" must be "true" or "false"' }); - return; - } - } - - const result = await registry.listCompanyAvailability(companyId, { available }); - res.json(result); - }); - - /** - * GET /api/companies/:companyId/plugins/:pluginId - * - * Resolve one plugin's effective availability for a company, whether that - * result comes from the default-enabled baseline or a persisted override row. - */ - router.get("/companies/:companyId/plugins/:pluginId", async (req, res) => { - assertBoard(req); - const { companyId, pluginId } = req.params; - assertCompanyAccess(req, companyId); - - const plugin = await resolvePlugin(registry, pluginId); - if (!plugin || plugin.status === "uninstalled") { - res.status(404).json({ error: "Plugin not found" }); - return; - } - - const result = await registry.getCompanyAvailability(companyId, plugin.id); - if (!result) { - res.status(404).json({ error: "Plugin not found" }); - return; - } - - res.json(result); - }); - - /** - * PUT /api/companies/:companyId/plugins/:pluginId - * - * Persist a company-scoped availability override. This never changes the - * instance-wide install state of the plugin; it only controls whether the - * selected company can see UI contributions and invoke plugin-backed actions. - */ - router.put("/companies/:companyId/plugins/:pluginId", async (req, res) => { - assertBoard(req); - const { companyId, pluginId } = req.params; - assertCompanyAccess(req, companyId); - - const plugin = await resolvePlugin(registry, pluginId); - if (!plugin || plugin.status === "uninstalled") { - res.status(404).json({ error: "Plugin not found" }); - return; - } - - const parsed = updateCompanyPluginAvailabilitySchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid request body" }); - return; - } - - try { - const result = await registry.updateCompanyAvailability( - companyId, - plugin.id, - parsed.data as UpdateCompanyPluginAvailability, - ); - const actor = getActorInfo(req); - await logActivity(db, { - companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, - action: "plugin.company_settings.updated", - entityType: "plugin_company_settings", - entityId: `${companyId}:${plugin.id}`, - details: { - pluginId: plugin.id, - pluginKey: plugin.pluginKey, - available: result.available, - settingsJson: result.settingsJson, - lastError: result.lastError, - }, - }); - res.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - res.status(400).json({ error: message }); - } - }); - // =========================================================================== // Tool discovery and execution routes // =========================================================================== @@ -628,45 +483,11 @@ export function pluginRoutes( } const pluginId = req.query.pluginId as string | undefined; - const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined; - if (companyId) { - assertCompanyAccess(req, companyId); - } - const filter = pluginId ? { pluginId } : undefined; const tools = toolDeps.toolDispatcher.listToolsForAgent(filter); - if (!companyId) { - res.json(tools); - return; - } - - const availablePluginIds = new Set( - (await registry.listCompanyAvailability(companyId, { available: true })) - .map((entry) => entry.pluginId), - ); - res.json(tools.filter((tool) => availablePluginIds.has(tool.pluginId))); + res.json(tools); }); - /** - * Reject company-scoped plugin access when the plugin is disabled for the - * target company. This guard is reused across UI bridge and tool execution - * endpoints so every runtime surface honors the same availability rule. - */ - async function enforceCompanyPluginAvailability( - companyId: string, - pluginId: string, - res: Response, - ): Promise { - if (!await isPluginAvailableForCompany(registry, companyId, pluginId)) { - res.status(403).json({ - error: `Plugin "${pluginId}" is not enabled for company "${companyId}"`, - }); - return false; - } - - return true; - } - /** * POST /api/plugins/tools/execute * @@ -730,10 +551,6 @@ export function pluginRoutes( return; } - if (!await enforceCompanyPluginAvailability(runContext.companyId, registeredTool.pluginDbId, res)) { - return; - } - try { const result = await toolDeps.toolDispatcher.executeTool( tool, @@ -824,13 +641,6 @@ export function pluginRoutes( const existingPlugin = await registry.getByKey(discovered.manifest.id); if (existingPlugin) { await lifecycle.load(existingPlugin.id); - // Plugins should be enabled by default for all companies after install. - // Best-effort: default behavior is still enabled when no row exists. - try { - await registry.seedEnabledForAllCompanies(existingPlugin.id); - } catch { - // no-op - } const updated = await registry.getById(existingPlugin.id); await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, { pluginId: existingPlugin.id, @@ -859,7 +669,7 @@ export function pluginRoutes( interface PluginBridgeDataRequest { /** Plugin-defined data key (e.g. `"sync-health"`). */ key: string; - /** Optional company scope for enforcing company plugin availability. */ + /** Optional company scope for authorizing company-context bridge calls. */ companyId?: string; /** Optional context and query parameters from the UI. */ params?: Record; @@ -871,7 +681,7 @@ export function pluginRoutes( interface PluginBridgeActionRequest { /** Plugin-defined action key (e.g. `"resync"`). */ key: string; - /** Optional company scope for enforcing company plugin availability. */ + /** Optional company scope for authorizing company-context bridge calls. */ companyId?: string; /** Optional parameters from the UI. */ params?: Record; @@ -1010,9 +820,6 @@ export function pluginRoutes( if (body.companyId) { assertCompanyAccess(req, body.companyId); - if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { - return; - } } try { @@ -1096,9 +903,6 @@ export function pluginRoutes( if (body.companyId) { assertCompanyAccess(req, body.companyId); - if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { - return; - } } try { @@ -1182,9 +986,6 @@ export function pluginRoutes( if (body?.companyId) { assertCompanyAccess(req, body.companyId); - if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { - return; - } } try { @@ -1264,9 +1065,6 @@ export function pluginRoutes( if (body?.companyId) { assertCompanyAccess(req, body.companyId); - if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { - return; - } } try { @@ -1337,10 +1135,6 @@ export function pluginRoutes( assertCompanyAccess(req, companyId); - if (!await enforceCompanyPluginAvailability(companyId, plugin.id, res)) { - return; - } - // Set SSE headers res.writeHead(200, { "Content-Type": "text/event-stream", diff --git a/server/src/services/plugin-event-bus.ts b/server/src/services/plugin-event-bus.ts index 78184b47..130db895 100644 --- a/server/src/services/plugin-event-bus.ts +++ b/server/src/services/plugin-event-bus.ts @@ -112,60 +112,6 @@ function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean { return true; } -// --------------------------------------------------------------------------- -// Company availability checker -// --------------------------------------------------------------------------- - -/** - * Callback that checks whether a plugin is enabled for a given company. - * - * The event bus calls this during `emit()` to enforce company-scoped delivery: - * events are only delivered to a plugin if the plugin is enabled for the - * company that owns the event. - * - * Implementations should be fast — the bus caches results internally with a - * short TTL so the checker is not invoked on every single event. - * - * @param pluginKey The plugin registry key — the string passed to `forPlugin()` - * (e.g. `"acme.linear"`). This is the same key used throughout the bus - * internally and should not be confused with a numeric or UUID plugin ID. - * @param companyId UUID of the company to check availability for. - * - * Return `true` if the plugin is enabled (or if no settings row exists, i.e. - * default-enabled), `false` if the company has explicitly disabled the plugin. - */ -export type CompanyAvailabilityChecker = ( - pluginKey: string, - companyId: string, -) => Promise; - -/** - * Options for {@link createPluginEventBus}. - */ -export interface PluginEventBusOptions { - /** - * Optional checker that gates event delivery per company. - * - * When provided, the bus will skip delivery to a plugin if the checker - * returns `false` for the `(pluginKey, event.companyId)` pair, where - * `pluginKey` is the registry key supplied to `forPlugin()`. Results are - * cached with a short TTL (30 s) to avoid excessive lookups. - * - * When omitted, no company-scoping is applied (useful in tests). - */ - isPluginEnabledForCompany?: CompanyAvailabilityChecker; -} - -// Default cache TTL in milliseconds (30 seconds). -const AVAILABILITY_CACHE_TTL_MS = 30_000; - -// Maximum number of entries in the availability cache before it is cleared. -// Prevents unbounded memory growth in long-running processes with many unique -// (pluginKey, companyId) pairs. A full clear is intentionally simple — the -// cache is advisory (performance only) and a miss merely triggers one extra -// async lookup. -const MAX_AVAILABILITY_CACHE_SIZE = 10_000; - // --------------------------------------------------------------------------- // Event bus factory // --------------------------------------------------------------------------- @@ -200,40 +146,10 @@ const MAX_AVAILABILITY_CACHE_SIZE = 10_000; * }); * ``` */ -export function createPluginEventBus(options?: PluginEventBusOptions): PluginEventBus { - const checker = options?.isPluginEnabledForCompany ?? null; - +export function createPluginEventBus(): PluginEventBus { // Subscription registry: pluginKey → list of subscriptions const registry = new Map(); - // Short-TTL cache for company availability lookups: "pluginKey\0companyId" → { enabled, expiresAt } - const availabilityCache = new Map(); - - function cacheKey(pluginKey: string, companyId: string): string { - return `${pluginKey}\0${companyId}`; - } - - /** - * Check whether a plugin is enabled for a company, using the cached result - * when available and falling back to the injected checker. - */ - async function isEnabledForCompany(pluginKey: string, companyId: string): Promise { - if (!checker) return true; - - const key = cacheKey(pluginKey, companyId); - const cached = availabilityCache.get(key); - if (cached && cached.expiresAt > Date.now()) { - return cached.enabled; - } - - const enabled = await checker(pluginKey, companyId); - if (availabilityCache.size >= MAX_AVAILABILITY_CACHE_SIZE) { - availabilityCache.clear(); - } - availabilityCache.set(key, { enabled, expiresAt: Date.now() + AVAILABILITY_CACHE_TTL_MS }); - return enabled; - } - /** * Retrieve or create the subscription list for a plugin. */ @@ -257,26 +173,7 @@ export function createPluginEventBus(options?: PluginEventBusOptions): PluginEve const errors: Array<{ pluginId: string; error: unknown }> = []; const promises: Promise[] = []; - // Pre-compute company availability for all registered plugins when the - // event carries a companyId and a checker is configured. This batches - // the (potentially async) lookups so we don't interleave them with - // handler dispatch. - let disabledPlugins: Set | null = null; - if (checker && event.companyId) { - const pluginKeys = Array.from(registry.keys()); - const checks = await Promise.all( - pluginKeys.map(async (pluginKey) => ({ - pluginKey, - enabled: await isEnabledForCompany(pluginKey, event.companyId!), - })), - ); - disabledPlugins = new Set(checks.filter((c) => !c.enabled).map((c) => c.pluginKey)); - } - for (const [pluginId, subs] of registry) { - // Skip delivery to plugins that are disabled for this company. - if (disabledPlugins?.has(pluginId)) continue; - for (const sub of subs) { if (!matchesPattern(event.eventType, sub.eventPattern)) continue; if (!passesFilter(event, sub.filter)) continue; diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index d7910e97..194a6116 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -466,18 +466,11 @@ export function buildHostServices( }; /** - * Verify that this plugin is enabled for the given company. - * Throws if the plugin is disabled or unavailable, preventing - * worker-driven access to companies that have not opted in. + * 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. */ - const ensurePluginAvailableForCompany = async (companyId: string) => { - const availability = await registry.getCompanyAvailability(companyId, pluginId); - if (!availability || !availability.available) { - throw new Error( - `Plugin "${pluginKey}" is not enabled for company "${companyId}"`, - ); - } - }; + const ensurePluginAvailableForCompany = async (_companyId: string) => {}; const inCompany = ( record: T | null | undefined, @@ -656,14 +649,7 @@ export function buildHostServices( companies: { async list(_params) { - const allCompanies = (await companies.list()) as Company[]; - if (allCompanies.length === 0) return []; - - // Batch query: fetch all company settings for this plugin in one query - // instead of N+1 individual getCompanyAvailability() calls. - const companyIds = allCompanies.map((c) => c.id); - const disabledCompanyIds = await registry.getDisabledCompanyIds(companyIds, pluginId); - return allCompanies.filter((c) => !disabledCompanyIds.has(c.id)); + return (await companies.list()) as Company[]; }, async get(params) { await ensurePluginAvailableForCompany(params.companyId); diff --git a/server/src/services/plugin-lifecycle.ts b/server/src/services/plugin-lifecycle.ts index d3e93677..e9f9b02b 100644 --- a/server/src/services/plugin-lifecycle.ts +++ b/server/src/services/plugin-lifecycle.ts @@ -431,6 +431,22 @@ export function pluginLifecycleManager( } } + async function deactivatePluginRuntime( + pluginId: string, + pluginKey: string, + ): Promise { + const supportsRuntimeDeactivation = + typeof pluginLoaderInstance.hasRuntimeServices === "function" + && typeof pluginLoaderInstance.unloadSingle === "function"; + + if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) { + await pluginLoaderInstance.unloadSingle(pluginId, pluginKey); + return; + } + + await stopWorkerIfRunning(pluginId, pluginKey); + } + // ----------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------- @@ -504,8 +520,7 @@ export function pluginLifecycleManager( ); } - // Stop the worker before transitioning state - await stopWorkerIfRunning(pluginId, plugin.pluginKey); + await deactivatePluginRuntime(pluginId, plugin.pluginKey); const result = await transition(pluginId, "disabled", reason ?? null, plugin); emitDomain("plugin.disabled", { @@ -526,6 +541,7 @@ export function pluginLifecycleManager( // If already uninstalled and removeData, hard-delete if (plugin.status === "uninstalled") { if (removeData) { + await pluginLoaderInstance.cleanupInstallArtifacts(plugin); const deleted = await registry.uninstall(pluginId, true); log.info( { pluginId, pluginKey: plugin.pluginKey }, @@ -544,8 +560,8 @@ export function pluginLifecycleManager( ); } - // Stop the worker before uninstalling - await stopWorkerIfRunning(pluginId, plugin.pluginKey); + await deactivatePluginRuntime(pluginId, plugin.pluginKey); + await pluginLoaderInstance.cleanupInstallArtifacts(plugin); // Perform the uninstall via registry (handles soft/hard delete) const result = await registry.uninstall(pluginId, removeData); @@ -577,7 +593,7 @@ export function pluginLifecycleManager( // continue running. The worker manager's auto-restart is disabled // because we are intentionally taking the plugin offline. const plugin = await requirePlugin(pluginId); - await stopWorkerIfRunning(pluginId, plugin.pluginKey); + await deactivatePluginRuntime(pluginId, plugin.pluginKey); const result = await transition(pluginId, "error", error, plugin); emitDomain("plugin.error", { @@ -590,9 +606,8 @@ export function pluginLifecycleManager( // -- markUpgradePending ----------------------------------------------- async markUpgradePending(pluginId: string): Promise { - // Stop the worker while waiting for operator approval of new capabilities const plugin = await requirePlugin(pluginId); - await stopWorkerIfRunning(pluginId, plugin.pluginKey); + await deactivatePluginRuntime(pluginId, plugin.pluginKey); const result = await transition(pluginId, "upgrade_pending", null, plugin); emitDomain("plugin.upgrade_pending", { @@ -637,8 +652,7 @@ export function pluginLifecycleManager( "plugin lifecycle: upgrade requested", ); - // Stop the current worker before upgrading on disk - await stopWorkerIfRunning(pluginId, plugin.pluginKey); + await deactivatePluginRuntime(pluginId, plugin.pluginKey); // 1. Download and validate new package via loader const { oldManifest, newManifest, discovered } = diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 7041bc23..bd8067c8 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -25,7 +25,7 @@ * @see PLUGIN_SPEC.md §12 — Process Model */ import { existsSync } from "node:fs"; -import { readdir, readFile, stat } from "node:fs/promises"; +import { readdir, readFile, rm, stat } from "node:fs/promises"; import { execFile } from "node:child_process"; import os from "node:os"; import path from "node:path"; @@ -394,6 +394,14 @@ export interface PluginLoader { */ isSupportedApiVersion(apiVersion: number): boolean; + /** + * Remove runtime-managed on-disk install artifacts for a plugin. + * + * This only cleans files under the managed local plugin directory. Local-path + * source checkouts outside that directory are intentionally left alone. + */ + cleanupInstallArtifacts(plugin: PluginRecord): Promise; + /** * Get the local plugin directory this loader is configured to use. */ @@ -1334,6 +1342,50 @@ export function pluginLoader( return manifestValidator.getSupportedVersions().includes(apiVersion); }, + // ----------------------------------------------------------------------- + // cleanupInstallArtifacts + // ----------------------------------------------------------------------- + + async cleanupInstallArtifacts(plugin: PluginRecord): Promise { + const managedTargets = new Set(); + const managedNodeModulesDir = resolveManagedInstallPackageDir(localPluginDir, plugin.packageName); + const directManagedDir = path.join(localPluginDir, plugin.packageName); + + managedTargets.add(managedNodeModulesDir); + if (isPathInsideDir(directManagedDir, localPluginDir)) { + managedTargets.add(directManagedDir); + } + if (plugin.packagePath && isPathInsideDir(plugin.packagePath, localPluginDir)) { + managedTargets.add(path.resolve(plugin.packagePath)); + } + + const packageJsonPath = path.join(localPluginDir, "package.json"); + if (existsSync(packageJsonPath)) { + try { + await execFileAsync( + "npm", + ["uninstall", plugin.packageName, "--prefix", localPluginDir, "--ignore-scripts"], + { timeout: 120_000 }, + ); + } catch (err) { + log.warn( + { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + packageName: plugin.packageName, + err: err instanceof Error ? err.message : String(err), + }, + "plugin-loader: npm uninstall failed during cleanup, falling back to direct removal", + ); + } + } + + for (const target of managedTargets) { + if (!existsSync(target)) continue; + await rm(target, { recursive: true, force: true }); + } + }, + // ----------------------------------------------------------------------- // getLocalPluginDir // ----------------------------------------------------------------------- @@ -1850,3 +1902,17 @@ function resolveWorkerEntrypoint( `${path.resolve(directDir, workerRelPath)}`, ); } + +function resolveManagedInstallPackageDir(localPluginDir: string, packageName: string): string { + if (packageName.startsWith("@")) { + return path.join(localPluginDir, "node_modules", ...packageName.split("/")); + } + return path.join(localPluginDir, "node_modules", packageName); +} + +function isPathInsideDir(candidatePath: string, parentDir: string): boolean { + const resolvedCandidate = path.resolve(candidatePath); + const resolvedParent = path.resolve(parentDir); + const relative = path.relative(resolvedParent, resolvedCandidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index eb4495a5..79859a4e 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -1,10 +1,8 @@ -import { asc, eq, ne, sql, and, inArray } from "drizzle-orm"; +import { asc, eq, ne, sql, and } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { plugins, - companies, pluginConfig, - pluginCompanySettings, pluginEntities, pluginJobs, pluginJobRuns, @@ -17,10 +15,6 @@ import type { UpdatePluginStatus, UpsertPluginConfig, PatchPluginConfig, - PluginCompanySettings, - CompanyPluginAvailability, - UpsertPluginCompanySettings, - UpdateCompanyPluginAvailability, PluginEntityRecord, PluginEntityQuery, PluginJobRecord, @@ -92,54 +86,6 @@ export function pluginRegistryService(db: Db) { return (result[0]?.maxOrder ?? 0) + 1; } - /** - * Load the persisted company override row for a plugin, if one exists. - * - * Missing rows are meaningful: the company inherits the default-enabled - * behavior and the caller should treat the plugin as available. - */ - async function getCompanySettingsRow(companyId: string, pluginId: string) { - return db - .select() - .from(pluginCompanySettings) - .where(and( - eq(pluginCompanySettings.companyId, companyId), - eq(pluginCompanySettings.pluginId, pluginId), - )) - .then((rows) => rows[0] ?? null); - } - - /** - * Normalize registry records into the API response returned by company - * plugin availability routes. - * - * The key business rule is captured here: plugins are enabled for a company - * unless an explicit `plugin_company_settings.enabled = false` override says - * otherwise. - */ - function toCompanyAvailability( - companyId: string, - plugin: Awaited>, - settings: PluginCompanySettings | null, - ): CompanyPluginAvailability { - if (!plugin) { - throw notFound("Plugin not found"); - } - - return { - companyId, - pluginId: plugin.id, - pluginKey: plugin.pluginKey, - pluginDisplayName: plugin.manifestJson.displayName, - pluginStatus: plugin.status, - available: settings?.enabled ?? true, - settingsJson: settings?.settingsJson ?? {}, - lastError: settings?.lastError ?? null, - createdAt: settings?.createdAt ?? null, - updatedAt: settings?.updatedAt ?? null, - }; - } - // ----------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------- @@ -416,233 +362,6 @@ export function pluginRegistryService(db: Db) { .then((rows) => rows[0]); }, - // ----- Company-scoped settings ---------------------------------------- - - /** Retrieve a plugin's company-scoped settings row, if any. */ - getCompanySettings: (companyId: string, pluginId: string) => - getCompanySettingsRow(companyId, pluginId), - - /** Create or replace the company-scoped settings row for a plugin. */ - upsertCompanySettings: async ( - companyId: string, - pluginId: string, - input: UpsertPluginCompanySettings, - ) => { - const plugin = await getById(pluginId); - if (!plugin) throw notFound("Plugin not found"); - - const existing = await getCompanySettingsRow(companyId, pluginId); - if (existing) { - return db - .update(pluginCompanySettings) - .set({ - enabled: true, - settingsJson: input.settingsJson ?? {}, - lastError: input.lastError ?? null, - updatedAt: new Date(), - }) - .where(eq(pluginCompanySettings.id, existing.id)) - .returning() - .then((rows) => rows[0]); - } - - return db - .insert(pluginCompanySettings) - .values({ - companyId, - pluginId, - enabled: true, - settingsJson: input.settingsJson ?? {}, - lastError: input.lastError ?? null, - }) - .returning() - .then((rows) => rows[0]); - }, - - /** Delete the company-scoped settings row for a plugin if it exists. */ - deleteCompanySettings: async (companyId: string, pluginId: string) => { - const plugin = await getById(pluginId); - if (!plugin) throw notFound("Plugin not found"); - - const existing = await getCompanySettingsRow(companyId, pluginId); - if (!existing) return null; - - return db - .delete(pluginCompanySettings) - .where(eq(pluginCompanySettings.id, existing.id)) - .returning() - .then((rows) => rows[0] ?? null); - }, - - /** List normalized company-plugin availability records across installed plugins. */ - listCompanyAvailability: async ( - companyId: string, - filter?: { available?: boolean }, - ) => { - const installed = await db - .select() - .from(plugins) - .where(ne(plugins.status, "uninstalled")) - .orderBy(asc(plugins.installOrder)); - - const settingsRows = await db - .select() - .from(pluginCompanySettings) - .where(eq(pluginCompanySettings.companyId, companyId)); - - const settingsByPluginId = new Map(settingsRows.map((row) => [row.pluginId, row])); - const availability = installed.map((plugin) => { - const row = settingsByPluginId.get(plugin.id) ?? null; - return { - ...toCompanyAvailability(companyId, plugin, row), - }; - }); - - if (filter?.available === undefined) return availability; - return availability.filter((item) => item.available === filter.available); - }, - - /** - * Batch-check which companies have this plugin explicitly disabled. - * Returns a Set of companyIds where `enabled = false`. Companies with - * no settings row default to enabled, so they are NOT in the result set. - */ - getDisabledCompanyIds: async (companyIds: string[], pluginId: string): Promise> => { - if (companyIds.length === 0) return new Set(); - const rows = await db - .select({ - companyId: pluginCompanySettings.companyId, - enabled: pluginCompanySettings.enabled, - }) - .from(pluginCompanySettings) - .where(and( - inArray(pluginCompanySettings.companyId, companyIds), - eq(pluginCompanySettings.pluginId, pluginId), - )); - const disabled = new Set(); - for (const row of rows) { - if (!row.enabled) disabled.add(row.companyId); - } - return disabled; - }, - - /** Get the normalized availability record for a single company/plugin pair. */ - getCompanyAvailability: async (companyId: string, pluginId: string) => { - const plugin = await getById(pluginId); - if (!plugin || plugin.status === "uninstalled") return null; - - const settings = await getCompanySettingsRow(companyId, pluginId); - return toCompanyAvailability(companyId, plugin, settings); - }, - - /** Update normalized company availability, persisting or deleting settings as needed. */ - updateCompanyAvailability: async ( - companyId: string, - pluginId: string, - input: UpdateCompanyPluginAvailability, - ) => { - const plugin = await getById(pluginId); - if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found"); - - const existing = await getCompanySettingsRow(companyId, pluginId); - - if (!input.available) { - const row = await (existing - ? db - .update(pluginCompanySettings) - .set({ - enabled: false, - settingsJson: input.settingsJson ?? existing.settingsJson, - lastError: input.lastError ?? existing.lastError ?? null, - updatedAt: new Date(), - }) - .where(eq(pluginCompanySettings.id, existing.id)) - .returning() - .then((rows) => rows[0]) - : db - .insert(pluginCompanySettings) - .values({ - companyId, - pluginId, - enabled: false, - settingsJson: input.settingsJson ?? {}, - lastError: input.lastError ?? null, - }) - .returning() - .then((rows) => rows[0])); - - return { - ...toCompanyAvailability(companyId, plugin, row), - }; - } - - const row = await (existing - ? db - .update(pluginCompanySettings) - .set({ - enabled: true, - settingsJson: input.settingsJson ?? existing.settingsJson, - lastError: input.lastError ?? existing.lastError ?? null, - updatedAt: new Date(), - }) - .where(eq(pluginCompanySettings.id, existing.id)) - .returning() - .then((rows) => rows[0]) - : db - .insert(pluginCompanySettings) - .values({ - companyId, - pluginId, - enabled: true, - settingsJson: input.settingsJson ?? {}, - lastError: input.lastError ?? null, - }) - .returning() - .then((rows) => rows[0])); - - return { - ...toCompanyAvailability(companyId, plugin, row), - }; - }, - - /** - * Ensure all companies have an explicit enabled row for this plugin. - * - * Company availability defaults to enabled when no row exists, but this - * helper persists explicit `enabled=true` rows so newly-installed plugins - * appear as enabled immediately and consistently in company-scoped views. - */ - seedEnabledForAllCompanies: async (pluginId: string) => { - const plugin = await getById(pluginId); - if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found"); - - const companyRows = await db - .select({ id: companies.id }) - .from(companies); - - if (companyRows.length === 0) return 0; - - const now = new Date(); - await db - .insert(pluginCompanySettings) - .values( - companyRows.map((company) => ({ - companyId: company.id, - pluginId, - enabled: true, - settingsJson: {}, - lastError: null, - createdAt: now, - updatedAt: now, - })), - ) - .onConflictDoNothing({ - target: [pluginCompanySettings.companyId, pluginCompanySettings.pluginId], - }); - - return companyRows.length; - }, - /** * Record an error against a plugin's config (e.g. validation failure * against the plugin's instanceConfigSchema). diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts index a2156b38..29d3a2d4 100644 --- a/server/src/services/plugin-secrets-handler.ts +++ b/server/src/services/plugin-secrets-handler.ts @@ -321,19 +321,6 @@ export function createPluginSecretsHandler( throw secretNotFound(trimmedRef); } - // --------------------------------------------------------------- - // 2b. Verify the plugin is available for the secret's company. - // This prevents cross-company secret access via UUID guessing. - // --------------------------------------------------------------- - const companyId = (secret as { companyId?: string }).companyId; - if (companyId) { - const availability = await registry.getCompanyAvailability(companyId, pluginId); - if (!availability || !availability.available) { - // Return the same error as "not found" to avoid leaking existence - throw secretNotFound(trimmedRef); - } - } - // --------------------------------------------------------------- // 3. Fetch the latest version's material // --------------------------------------------------------------- diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index f2b7d31b..0edc580f 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -17,7 +17,6 @@ import type { PluginRecord, PluginConfig, PluginStatus, - CompanyPluginAvailability, } from "@paperclipai/shared"; import { api } from "./client"; @@ -280,9 +279,6 @@ export const pluginsApi = { * Returns normalized UI contribution declarations for ready plugins. * Used by the slot host runtime and launcher discovery surfaces. * - * When `companyId` is provided, the server filters out plugins that are - * disabled for that company before returning contributions. - * * Response shape: * - `slots`: concrete React mount declarations from `manifest.ui.slots` * - `launchers`: host-owned entry points from `manifest.ui.launchers` plus @@ -290,54 +286,14 @@ export const pluginsApi = { * * @example * ```ts - * const rows = await pluginsApi.listUiContributions(companyId); + * const rows = await pluginsApi.listUiContributions(); * const toolbarLaunchers = rows.flatMap((row) => * row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"), * ); * ``` */ - listUiContributions: (companyId?: string) => - api.get( - `/plugins/ui-contributions${companyId ? `?companyId=${encodeURIComponent(companyId)}` : ""}`, - ), - - /** - * List plugin availability/settings for a specific company. - * - * @param companyId - UUID of the company. - * @param available - Optional availability filter. - */ - listForCompany: (companyId: string, available?: boolean) => - api.get( - `/companies/${companyId}/plugins${available === undefined ? "" : `?available=${available}`}`, - ), - - /** - * Fetch a single company-scoped plugin availability/settings record. - * - * @param companyId - UUID of the company. - * @param pluginId - Plugin UUID or plugin key. - */ - getForCompany: (companyId: string, pluginId: string) => - api.get(`/companies/${companyId}/plugins/${pluginId}`), - - /** - * Create, update, or clear company-scoped plugin settings. - * - * Company availability is enabled by default. This endpoint stores explicit - * overrides in `plugin_company_settings` so the selected company can be - * disabled without affecting the global plugin installation. - */ - saveForCompany: ( - companyId: string, - pluginId: string, - params: { - available: boolean; - settingsJson?: Record; - lastError?: string | null; - }, - ) => - api.put(`/companies/${companyId}/plugins/${pluginId}`, params), + listUiContributions: () => + api.get("/plugins/ui-contributions"), // =========================================================================== // Plugin configuration endpoints @@ -398,8 +354,7 @@ export const pluginsApi = { * @param pluginId - UUID of the plugin whose worker should handle the request * @param key - Plugin-defined data key (e.g. `"sync-health"`) * @param params - Optional query parameters forwarded to the worker handler - * @param companyId - Optional company scope. When present, the server rejects - * the call with HTTP 403 if the plugin is disabled for that company. + * @param companyId - Optional company scope used for board/company access checks. * @param renderEnvironment - Optional launcher/page snapshot forwarded for * launcher-backed UI so workers can distinguish modal, drawer, popover, and * page execution. @@ -439,8 +394,7 @@ export const pluginsApi = { * @param pluginId - UUID of the plugin whose worker should handle the request * @param key - Plugin-defined action key (e.g. `"resync"`) * @param params - Optional parameters forwarded to the worker handler - * @param companyId - Optional company scope. When present, the server rejects - * the call with HTTP 403 if the plugin is disabled for that company. + * @param companyId - Optional company scope used for board/company access checks. * @param renderEnvironment - Optional launcher/page snapshot forwarded for * launcher-backed UI so workers can distinguish modal, drawer, popover, and * page execution. diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index a41c5d31..70e0b888 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -80,15 +80,9 @@ export const queryKeys = { examples: ["plugins", "examples"] as const, detail: (pluginId: string) => ["plugins", pluginId] as const, health: (pluginId: string) => ["plugins", pluginId, "health"] as const, - uiContributions: (companyId?: string | null) => - ["plugins", "ui-contributions", companyId ?? "global"] as const, + uiContributions: ["plugins", "ui-contributions"] as const, config: (pluginId: string) => ["plugins", pluginId, "config"] as const, dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const, logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const, - company: (companyId: string) => ["plugins", "company", companyId] as const, - companyList: (companyId: string, available?: boolean) => - ["plugins", "company", companyId, "list", available ?? "all"] as const, - companyDetail: (companyId: string, pluginId: string) => - ["plugins", "company", companyId, pluginId] as const, }, }; diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx index de36f658..3d422f23 100644 --- a/ui/src/pages/PluginManager.tsx +++ b/ui/src/pages/PluginManager.tsx @@ -61,7 +61,7 @@ function getPluginErrorSummary(plugin: PluginRecord): string { * @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics. */ export function PluginManager() { - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const { pushToast } = useToast(); @@ -93,10 +93,7 @@ export function PluginManager() { const invalidatePluginQueries = () => { queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples }); - queryClient.invalidateQueries({ queryKey: ["plugins", "ui-contributions"] }); - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.plugins.companyList(selectedCompanyId) }); - } + queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions }); }; const installMutation = useMutation({ diff --git a/ui/src/pages/PluginPage.tsx b/ui/src/pages/PluginPage.tsx index 5813fd07..345579f7 100644 --- a/ui/src/pages/PluginPage.tsx +++ b/ui/src/pages/PluginPage.tsx @@ -37,8 +37,8 @@ export function PluginPage() { ); const { data: contributions } = useQuery({ - queryKey: queryKeys.plugins.uiContributions(resolvedCompanyId ?? undefined), - queryFn: () => pluginsApi.listUiContributions(resolvedCompanyId ?? undefined), + queryKey: queryKeys.plugins.uiContributions, + queryFn: () => pluginsApi.listUiContributions(), enabled: !!resolvedCompanyId && !!pluginId, }); diff --git a/ui/src/plugins/launchers.tsx b/ui/src/plugins/launchers.tsx index b03b527a..1804d58f 100644 --- a/ui/src/plugins/launchers.tsx +++ b/ui/src/plugins/launchers.tsx @@ -261,8 +261,8 @@ export function usePluginLaunchers( ): UsePluginLaunchersResult { const queryEnabled = filters.enabled ?? true; const { data, isLoading, error } = useQuery({ - queryKey: queryKeys.plugins.uiContributions(filters.companyId), - queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined), + queryKey: queryKeys.plugins.uiContributions, + queryFn: () => pluginsApi.listUiContributions(), enabled: queryEnabled, }); diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index af800c91..0b1e39fb 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -552,8 +552,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(filters.companyId), - queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined), + queryKey: queryKeys.plugins.uiContributions, + queryFn: () => pluginsApi.listUiContributions(), enabled: queryEnabled, });