Simplify plugin runtime and cleanup lifecycle

This commit is contained in:
Dotta
2026-03-13 16:58:29 -05:00
parent 80cdbdbd47
commit 12ccfc2c9a
21 changed files with 120 additions and 838 deletions

View File

@@ -65,10 +65,10 @@ const plugin = definePlugin({
async setup(ctx) { async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`); ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
// Expose the current plugin config so UI components can read the // Expose the current plugin config so UI components can read operator
// commentAnnotationMode setting and hide themselves when disabled. // settings from the canonical instance config store.
ctx.data.register("plugin-config", async () => { ctx.data.register("plugin-config", async () => {
const config = await ctx.state.get({ scopeKind: "instance", stateKey: "config" }) as Record<string, unknown> | null; const config = await ctx.config.get();
return { return {
showFilesInSidebar: config?.showFilesInSidebar === true, showFilesInSidebar: config?.showFilesInSidebar === true,
commentAnnotationMode: config?.commentAnnotationMode ?? "both", commentAnnotationMode: config?.commentAnnotationMode ?? "both",

View File

@@ -220,7 +220,7 @@ The same set of values is used as **slot types** (where a component mounts) and
#### `page` #### `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` #### `sidebar`

View File

@@ -177,8 +177,6 @@ export type {
PluginRecord, PluginRecord,
PluginStateRecord, PluginStateRecord,
PluginConfig, PluginConfig,
PluginCompanySettings,
CompanyPluginAvailability,
PluginEntityRecord, PluginEntityRecord,
PluginEntityQuery, PluginEntityQuery,
PluginJobRecord, PluginJobRecord,
@@ -305,9 +303,6 @@ export {
installPluginSchema, installPluginSchema,
upsertPluginConfigSchema, upsertPluginConfigSchema,
patchPluginConfigSchema, patchPluginConfigSchema,
upsertPluginCompanySettingsSchema,
updateCompanyPluginAvailabilitySchema,
listCompanyPluginAvailabilitySchema,
updatePluginStatusSchema, updatePluginStatusSchema,
uninstallPluginSchema, uninstallPluginSchema,
pluginStateScopeKeySchema, pluginStateScopeKeySchema,
@@ -324,9 +319,6 @@ export {
type InstallPlugin, type InstallPlugin,
type UpsertPluginConfig, type UpsertPluginConfig,
type PatchPluginConfig, type PatchPluginConfig,
type UpsertPluginCompanySettings,
type UpdateCompanyPluginAvailability,
type ListCompanyPluginAvailability,
type UpdatePluginStatus, type UpdatePluginStatus,
type UninstallPlugin, type UninstallPlugin,
type PluginStateScopeKey, type PluginStateScopeKey,

View File

@@ -95,8 +95,6 @@ export type {
PluginRecord, PluginRecord,
PluginStateRecord, PluginStateRecord,
PluginConfig, PluginConfig,
PluginCompanySettings,
CompanyPluginAvailability,
PluginEntityRecord, PluginEntityRecord,
PluginEntityQuery, PluginEntityQuery,
PluginJobRecord, PluginJobRecord,

View File

@@ -343,67 +343,6 @@ export interface PluginConfig {
updatedAt: Date; 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<string, unknown>;
/** 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<string, unknown>;
/** 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`. * Query filter for `ctx.entities.list`.
*/ */

View File

@@ -151,9 +151,6 @@ export {
installPluginSchema, installPluginSchema,
upsertPluginConfigSchema, upsertPluginConfigSchema,
patchPluginConfigSchema, patchPluginConfigSchema,
upsertPluginCompanySettingsSchema,
updateCompanyPluginAvailabilitySchema,
listCompanyPluginAvailabilitySchema,
updatePluginStatusSchema, updatePluginStatusSchema,
uninstallPluginSchema, uninstallPluginSchema,
pluginStateScopeKeySchema, pluginStateScopeKeySchema,
@@ -170,9 +167,6 @@ export {
type InstallPlugin, type InstallPlugin,
type UpsertPluginConfig, type UpsertPluginConfig,
type PatchPluginConfig, type PatchPluginConfig,
type UpsertPluginCompanySettings,
type UpdateCompanyPluginAvailability,
type ListCompanyPluginAvailability,
type UpdatePluginStatus, type UpdatePluginStatus,
type UninstallPlugin, type UninstallPlugin,
type PluginStateScopeKey, type PluginStateScopeKey,

View File

@@ -577,48 +577,6 @@ export const patchPluginConfigSchema = z.object({
export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>; export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>;
// ---------------------------------------------------------------------------
// 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<typeof upsertPluginCompanySettingsSchema>;
/**
* 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<typeof updateCompanyPluginAvailabilitySchema>;
/**
* Query schema for company plugin availability list endpoints.
*/
export const listCompanyPluginAvailabilitySchema = z.object({
available: z.boolean().optional(),
});
export type ListCompanyPluginAvailability = z.infer<typeof listCompanyPluginAvailabilitySchema>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin status update // Plugin status update
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -140,14 +140,7 @@ export async function createApp(
const hostServicesDisposers = new Map<string, () => void>(); const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager(); const workerManager = createPluginWorkerManager();
const pluginRegistry = pluginRegistryService(db); const pluginRegistry = pluginRegistryService(db);
const eventBus = createPluginEventBus({ 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 jobStore = pluginJobStore(db); const jobStore = pluginJobStore(db);
const lifecycle = pluginLifecycleManager(db, { workerManager }); const lifecycle = pluginLifecycleManager(db, { workerManager });
const scheduler = createPluginJobScheduler({ const scheduler = createPluginJobScheduler({

View File

@@ -22,7 +22,7 @@ import path from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { Router } from "express"; import { Router } from "express";
import type { Request, Response } from "express"; import type { Request } from "express";
import { and, desc, eq, gte } from "drizzle-orm"; import { and, desc, eq, gte } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db"; import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db";
@@ -31,11 +31,9 @@ import type {
PaperclipPluginManifestV1, PaperclipPluginManifestV1,
PluginBridgeErrorCode, PluginBridgeErrorCode,
PluginLauncherRenderContextSnapshot, PluginLauncherRenderContextSnapshot,
UpdateCompanyPluginAvailability,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { import {
PLUGIN_STATUSES, PLUGIN_STATUSES,
updateCompanyPluginAvailabilitySchema,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { pluginRegistryService } from "../services/plugin-registry.js"; import { pluginRegistryService } from "../services/plugin-registry.js";
import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; import { pluginLifecycleManager } from "../services/plugin-lifecycle.js";
@@ -186,15 +184,6 @@ async function resolvePlugin(
return registry.getByKey(pluginId); return registry.getByKey(pluginId);
} }
async function isPluginAvailableForCompany(
registry: ReturnType<typeof pluginRegistryService>,
companyId: string,
pluginId: string,
): Promise<boolean> {
const availability = await registry.getCompanyAvailability(companyId, pluginId);
return availability?.available === true;
}
/** /**
* Optional dependencies for plugin job scheduling routes. * Optional dependencies for plugin job scheduling routes.
* *
@@ -284,9 +273,6 @@ interface PluginToolExecuteRequest {
* | GET | /plugins/:pluginId/config | Get current plugin config | * | GET | /plugins/:pluginId/config | Get current plugin config |
* | POST | /plugins/:pluginId/config | Save (upsert) plugin config | * | POST | /plugins/:pluginId/config | Save (upsert) plugin config |
* | POST | /plugins/:pluginId/config/test | Test config via validateConfig RPC | * | 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/data | Proxy getData to plugin worker |
* | POST | /plugins/:pluginId/bridge/action | Proxy performAction 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) | * | 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 * - Slots are extracted from manifest.ui.slots
* - Launchers are aggregated from legacy manifest.launchers and manifest.ui.launchers * - 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: * Example response:
* ```json * ```json
* [ * [
@@ -451,21 +433,9 @@ export function pluginRoutes(
*/ */
router.get("/plugins/ui-contributions", async (req, res) => { router.get("/plugins/ui-contributions", async (req, res) => {
assertBoard(req); 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 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 const contributions: PluginUiContribution[] = plugins
.filter((plugin) => availablePluginIds === null || availablePluginIds.has(plugin.id))
.map((plugin) => { .map((plugin) => {
// Safety check: manifestJson should always exist for ready plugins, but guard against null // Safety check: manifestJson should always exist for ready plugins, but guard against null
const manifest = plugin.manifestJson; const manifest = plugin.manifestJson;
@@ -489,121 +459,6 @@ export function pluginRoutes(
res.json(contributions); 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 // Tool discovery and execution routes
// =========================================================================== // ===========================================================================
@@ -628,45 +483,11 @@ export function pluginRoutes(
} }
const pluginId = req.query.pluginId as string | undefined; 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 filter = pluginId ? { pluginId } : undefined;
const tools = toolDeps.toolDispatcher.listToolsForAgent(filter); const tools = toolDeps.toolDispatcher.listToolsForAgent(filter);
if (!companyId) { res.json(tools);
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)));
}); });
/**
* 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<boolean> {
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 * POST /api/plugins/tools/execute
* *
@@ -730,10 +551,6 @@ export function pluginRoutes(
return; return;
} }
if (!await enforceCompanyPluginAvailability(runContext.companyId, registeredTool.pluginDbId, res)) {
return;
}
try { try {
const result = await toolDeps.toolDispatcher.executeTool( const result = await toolDeps.toolDispatcher.executeTool(
tool, tool,
@@ -824,13 +641,6 @@ export function pluginRoutes(
const existingPlugin = await registry.getByKey(discovered.manifest.id); const existingPlugin = await registry.getByKey(discovered.manifest.id);
if (existingPlugin) { if (existingPlugin) {
await lifecycle.load(existingPlugin.id); 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); const updated = await registry.getById(existingPlugin.id);
await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, { await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, {
pluginId: existingPlugin.id, pluginId: existingPlugin.id,
@@ -859,7 +669,7 @@ export function pluginRoutes(
interface PluginBridgeDataRequest { interface PluginBridgeDataRequest {
/** Plugin-defined data key (e.g. `"sync-health"`). */ /** Plugin-defined data key (e.g. `"sync-health"`). */
key: string; key: string;
/** Optional company scope for enforcing company plugin availability. */ /** Optional company scope for authorizing company-context bridge calls. */
companyId?: string; companyId?: string;
/** Optional context and query parameters from the UI. */ /** Optional context and query parameters from the UI. */
params?: Record<string, unknown>; params?: Record<string, unknown>;
@@ -871,7 +681,7 @@ export function pluginRoutes(
interface PluginBridgeActionRequest { interface PluginBridgeActionRequest {
/** Plugin-defined action key (e.g. `"resync"`). */ /** Plugin-defined action key (e.g. `"resync"`). */
key: string; key: string;
/** Optional company scope for enforcing company plugin availability. */ /** Optional company scope for authorizing company-context bridge calls. */
companyId?: string; companyId?: string;
/** Optional parameters from the UI. */ /** Optional parameters from the UI. */
params?: Record<string, unknown>; params?: Record<string, unknown>;
@@ -1010,9 +820,6 @@ export function pluginRoutes(
if (body.companyId) { if (body.companyId) {
assertCompanyAccess(req, body.companyId); assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
} }
try { try {
@@ -1096,9 +903,6 @@ export function pluginRoutes(
if (body.companyId) { if (body.companyId) {
assertCompanyAccess(req, body.companyId); assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
} }
try { try {
@@ -1182,9 +986,6 @@ export function pluginRoutes(
if (body?.companyId) { if (body?.companyId) {
assertCompanyAccess(req, body.companyId); assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
} }
try { try {
@@ -1264,9 +1065,6 @@ export function pluginRoutes(
if (body?.companyId) { if (body?.companyId) {
assertCompanyAccess(req, body.companyId); assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
} }
try { try {
@@ -1337,10 +1135,6 @@ export function pluginRoutes(
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
if (!await enforceCompanyPluginAvailability(companyId, plugin.id, res)) {
return;
}
// Set SSE headers // Set SSE headers
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",

View File

@@ -112,60 +112,6 @@ function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean {
return true; 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<boolean>;
/**
* 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 // Event bus factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -200,40 +146,10 @@ const MAX_AVAILABILITY_CACHE_SIZE = 10_000;
* }); * });
* ``` * ```
*/ */
export function createPluginEventBus(options?: PluginEventBusOptions): PluginEventBus { export function createPluginEventBus(): PluginEventBus {
const checker = options?.isPluginEnabledForCompany ?? null;
// Subscription registry: pluginKey → list of subscriptions // Subscription registry: pluginKey → list of subscriptions
const registry = new Map<string, Subscription[]>(); const registry = new Map<string, Subscription[]>();
// Short-TTL cache for company availability lookups: "pluginKey\0companyId" → { enabled, expiresAt }
const availabilityCache = new Map<string, { enabled: boolean; expiresAt: number }>();
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<boolean> {
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. * 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 errors: Array<{ pluginId: string; error: unknown }> = [];
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
// 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<string> | 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) { 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) { for (const sub of subs) {
if (!matchesPattern(event.eventType, sub.eventPattern)) continue; if (!matchesPattern(event.eventType, sub.eventPattern)) continue;
if (!passesFilter(event, sub.filter)) continue; if (!passesFilter(event, sub.filter)) continue;

View File

@@ -466,18 +466,11 @@ export function buildHostServices(
}; };
/** /**
* Verify that this plugin is enabled for the given company. * Plugins are instance-wide in the current runtime. Company IDs are still
* Throws if the plugin is disabled or unavailable, preventing * required for company-scoped data access, but there is no per-company
* worker-driven access to companies that have not opted in. * availability gate to enforce here.
*/ */
const ensurePluginAvailableForCompany = async (companyId: string) => { 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 inCompany = <T extends { companyId: string | null | undefined }>( const inCompany = <T extends { companyId: string | null | undefined }>(
record: T | null | undefined, record: T | null | undefined,
@@ -656,14 +649,7 @@ export function buildHostServices(
companies: { companies: {
async list(_params) { async list(_params) {
const allCompanies = (await companies.list()) as Company[]; return (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));
}, },
async get(params) { async get(params) {
await ensurePluginAvailableForCompany(params.companyId); await ensurePluginAvailableForCompany(params.companyId);

View File

@@ -431,6 +431,22 @@ export function pluginLifecycleManager(
} }
} }
async function deactivatePluginRuntime(
pluginId: string,
pluginKey: string,
): Promise<void> {
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 // Public API
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -504,8 +520,7 @@ export function pluginLifecycleManager(
); );
} }
// Stop the worker before transitioning state await deactivatePluginRuntime(pluginId, plugin.pluginKey);
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
const result = await transition(pluginId, "disabled", reason ?? null, plugin); const result = await transition(pluginId, "disabled", reason ?? null, plugin);
emitDomain("plugin.disabled", { emitDomain("plugin.disabled", {
@@ -526,6 +541,7 @@ export function pluginLifecycleManager(
// If already uninstalled and removeData, hard-delete // If already uninstalled and removeData, hard-delete
if (plugin.status === "uninstalled") { if (plugin.status === "uninstalled") {
if (removeData) { if (removeData) {
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
const deleted = await registry.uninstall(pluginId, true); const deleted = await registry.uninstall(pluginId, true);
log.info( log.info(
{ pluginId, pluginKey: plugin.pluginKey }, { pluginId, pluginKey: plugin.pluginKey },
@@ -544,8 +560,8 @@ export function pluginLifecycleManager(
); );
} }
// Stop the worker before uninstalling await deactivatePluginRuntime(pluginId, plugin.pluginKey);
await stopWorkerIfRunning(pluginId, plugin.pluginKey); await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
// Perform the uninstall via registry (handles soft/hard delete) // Perform the uninstall via registry (handles soft/hard delete)
const result = await registry.uninstall(pluginId, removeData); const result = await registry.uninstall(pluginId, removeData);
@@ -577,7 +593,7 @@ export function pluginLifecycleManager(
// continue running. The worker manager's auto-restart is disabled // continue running. The worker manager's auto-restart is disabled
// because we are intentionally taking the plugin offline. // because we are intentionally taking the plugin offline.
const plugin = await requirePlugin(pluginId); const plugin = await requirePlugin(pluginId);
await stopWorkerIfRunning(pluginId, plugin.pluginKey); await deactivatePluginRuntime(pluginId, plugin.pluginKey);
const result = await transition(pluginId, "error", error, plugin); const result = await transition(pluginId, "error", error, plugin);
emitDomain("plugin.error", { emitDomain("plugin.error", {
@@ -590,9 +606,8 @@ export function pluginLifecycleManager(
// -- markUpgradePending ----------------------------------------------- // -- markUpgradePending -----------------------------------------------
async markUpgradePending(pluginId: string): Promise<PluginRecord> { async markUpgradePending(pluginId: string): Promise<PluginRecord> {
// Stop the worker while waiting for operator approval of new capabilities
const plugin = await requirePlugin(pluginId); const plugin = await requirePlugin(pluginId);
await stopWorkerIfRunning(pluginId, plugin.pluginKey); await deactivatePluginRuntime(pluginId, plugin.pluginKey);
const result = await transition(pluginId, "upgrade_pending", null, plugin); const result = await transition(pluginId, "upgrade_pending", null, plugin);
emitDomain("plugin.upgrade_pending", { emitDomain("plugin.upgrade_pending", {
@@ -637,8 +652,7 @@ export function pluginLifecycleManager(
"plugin lifecycle: upgrade requested", "plugin lifecycle: upgrade requested",
); );
// Stop the current worker before upgrading on disk await deactivatePluginRuntime(pluginId, plugin.pluginKey);
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
// 1. Download and validate new package via loader // 1. Download and validate new package via loader
const { oldManifest, newManifest, discovered } = const { oldManifest, newManifest, discovered } =

View File

@@ -25,7 +25,7 @@
* @see PLUGIN_SPEC.md §12 — Process Model * @see PLUGIN_SPEC.md §12 — Process Model
*/ */
import { existsSync } from "node:fs"; 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 { execFile } from "node:child_process";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -394,6 +394,14 @@ export interface PluginLoader {
*/ */
isSupportedApiVersion(apiVersion: number): boolean; 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<void>;
/** /**
* Get the local plugin directory this loader is configured to use. * Get the local plugin directory this loader is configured to use.
*/ */
@@ -1334,6 +1342,50 @@ export function pluginLoader(
return manifestValidator.getSupportedVersions().includes(apiVersion); return manifestValidator.getSupportedVersions().includes(apiVersion);
}, },
// -----------------------------------------------------------------------
// cleanupInstallArtifacts
// -----------------------------------------------------------------------
async cleanupInstallArtifacts(plugin: PluginRecord): Promise<void> {
const managedTargets = new Set<string>();
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 // getLocalPluginDir
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1850,3 +1902,17 @@ function resolveWorkerEntrypoint(
`${path.resolve(directDir, workerRelPath)}`, `${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));
}

View File

@@ -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 type { Db } from "@paperclipai/db";
import { import {
plugins, plugins,
companies,
pluginConfig, pluginConfig,
pluginCompanySettings,
pluginEntities, pluginEntities,
pluginJobs, pluginJobs,
pluginJobRuns, pluginJobRuns,
@@ -17,10 +15,6 @@ import type {
UpdatePluginStatus, UpdatePluginStatus,
UpsertPluginConfig, UpsertPluginConfig,
PatchPluginConfig, PatchPluginConfig,
PluginCompanySettings,
CompanyPluginAvailability,
UpsertPluginCompanySettings,
UpdateCompanyPluginAvailability,
PluginEntityRecord, PluginEntityRecord,
PluginEntityQuery, PluginEntityQuery,
PluginJobRecord, PluginJobRecord,
@@ -92,54 +86,6 @@ export function pluginRegistryService(db: Db) {
return (result[0]?.maxOrder ?? 0) + 1; 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<ReturnType<typeof getById>>,
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 // Public API
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -416,233 +362,6 @@ export function pluginRegistryService(db: Db) {
.then((rows) => rows[0]); .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<Set<string>> => {
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<string>();
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 * Record an error against a plugin's config (e.g. validation failure
* against the plugin's instanceConfigSchema). * against the plugin's instanceConfigSchema).

View File

@@ -321,19 +321,6 @@ export function createPluginSecretsHandler(
throw secretNotFound(trimmedRef); 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 // 3. Fetch the latest version's material
// --------------------------------------------------------------- // ---------------------------------------------------------------

View File

@@ -17,7 +17,6 @@ import type {
PluginRecord, PluginRecord,
PluginConfig, PluginConfig,
PluginStatus, PluginStatus,
CompanyPluginAvailability,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { api } from "./client"; import { api } from "./client";
@@ -280,9 +279,6 @@ export const pluginsApi = {
* Returns normalized UI contribution declarations for ready plugins. * Returns normalized UI contribution declarations for ready plugins.
* Used by the slot host runtime and launcher discovery surfaces. * 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: * Response shape:
* - `slots`: concrete React mount declarations from `manifest.ui.slots` * - `slots`: concrete React mount declarations from `manifest.ui.slots`
* - `launchers`: host-owned entry points from `manifest.ui.launchers` plus * - `launchers`: host-owned entry points from `manifest.ui.launchers` plus
@@ -290,54 +286,14 @@ export const pluginsApi = {
* *
* @example * @example
* ```ts * ```ts
* const rows = await pluginsApi.listUiContributions(companyId); * const rows = await pluginsApi.listUiContributions();
* const toolbarLaunchers = rows.flatMap((row) => * const toolbarLaunchers = rows.flatMap((row) =>
* row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"), * row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"),
* ); * );
* ``` * ```
*/ */
listUiContributions: (companyId?: string) => listUiContributions: () =>
api.get<PluginUiContribution[]>( api.get<PluginUiContribution[]>("/plugins/ui-contributions"),
`/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<CompanyPluginAvailability[]>(
`/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<CompanyPluginAvailability>(`/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<string, unknown>;
lastError?: string | null;
},
) =>
api.put<CompanyPluginAvailability>(`/companies/${companyId}/plugins/${pluginId}`, params),
// =========================================================================== // ===========================================================================
// Plugin configuration endpoints // Plugin configuration endpoints
@@ -398,8 +354,7 @@ export const pluginsApi = {
* @param pluginId - UUID of the plugin whose worker should handle the request * @param pluginId - UUID of the plugin whose worker should handle the request
* @param key - Plugin-defined data key (e.g. `"sync-health"`) * @param key - Plugin-defined data key (e.g. `"sync-health"`)
* @param params - Optional query parameters forwarded to the worker handler * @param params - Optional query parameters forwarded to the worker handler
* @param companyId - Optional company scope. When present, the server rejects * @param companyId - Optional company scope used for board/company access checks.
* the call with HTTP 403 if the plugin is disabled for that company.
* @param renderEnvironment - Optional launcher/page snapshot forwarded for * @param renderEnvironment - Optional launcher/page snapshot forwarded for
* launcher-backed UI so workers can distinguish modal, drawer, popover, and * launcher-backed UI so workers can distinguish modal, drawer, popover, and
* page execution. * page execution.
@@ -439,8 +394,7 @@ export const pluginsApi = {
* @param pluginId - UUID of the plugin whose worker should handle the request * @param pluginId - UUID of the plugin whose worker should handle the request
* @param key - Plugin-defined action key (e.g. `"resync"`) * @param key - Plugin-defined action key (e.g. `"resync"`)
* @param params - Optional parameters forwarded to the worker handler * @param params - Optional parameters forwarded to the worker handler
* @param companyId - Optional company scope. When present, the server rejects * @param companyId - Optional company scope used for board/company access checks.
* the call with HTTP 403 if the plugin is disabled for that company.
* @param renderEnvironment - Optional launcher/page snapshot forwarded for * @param renderEnvironment - Optional launcher/page snapshot forwarded for
* launcher-backed UI so workers can distinguish modal, drawer, popover, and * launcher-backed UI so workers can distinguish modal, drawer, popover, and
* page execution. * page execution.

View File

@@ -80,15 +80,9 @@ export const queryKeys = {
examples: ["plugins", "examples"] as const, examples: ["plugins", "examples"] as const,
detail: (pluginId: string) => ["plugins", pluginId] as const, detail: (pluginId: string) => ["plugins", pluginId] as const,
health: (pluginId: string) => ["plugins", pluginId, "health"] as const, health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
uiContributions: (companyId?: string | null) => uiContributions: ["plugins", "ui-contributions"] as const,
["plugins", "ui-contributions", companyId ?? "global"] as const,
config: (pluginId: string) => ["plugins", pluginId, "config"] as const, config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const, dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
logs: (pluginId: string) => ["plugins", pluginId, "logs"] 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,
}, },
}; };

View File

@@ -61,7 +61,7 @@ function getPluginErrorSummary(plugin: PluginRecord): string {
* @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics. * @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics.
*/ */
export function PluginManager() { export function PluginManager() {
const { selectedCompany, selectedCompanyId } = useCompany(); const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pushToast } = useToast(); const { pushToast } = useToast();
@@ -93,10 +93,7 @@ export function PluginManager() {
const invalidatePluginQueries = () => { const invalidatePluginQueries = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples });
queryClient.invalidateQueries({ queryKey: ["plugins", "ui-contributions"] }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.companyList(selectedCompanyId) });
}
}; };
const installMutation = useMutation({ const installMutation = useMutation({

View File

@@ -37,8 +37,8 @@ export function PluginPage() {
); );
const { data: contributions } = useQuery({ const { data: contributions } = useQuery({
queryKey: queryKeys.plugins.uiContributions(resolvedCompanyId ?? undefined), queryKey: queryKeys.plugins.uiContributions,
queryFn: () => pluginsApi.listUiContributions(resolvedCompanyId ?? undefined), queryFn: () => pluginsApi.listUiContributions(),
enabled: !!resolvedCompanyId && !!pluginId, enabled: !!resolvedCompanyId && !!pluginId,
}); });

View File

@@ -261,8 +261,8 @@ export function usePluginLaunchers(
): UsePluginLaunchersResult { ): UsePluginLaunchersResult {
const queryEnabled = filters.enabled ?? true; const queryEnabled = filters.enabled ?? true;
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: queryKeys.plugins.uiContributions(filters.companyId), queryKey: queryKeys.plugins.uiContributions,
queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined), queryFn: () => pluginsApi.listUiContributions(),
enabled: queryEnabled, enabled: queryEnabled,
}); });

View File

@@ -552,8 +552,8 @@ function usePluginModuleLoader(contributions: PluginUiContribution[] | undefined
export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult { export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult {
const queryEnabled = filters.enabled ?? true; const queryEnabled = filters.enabled ?? true;
const { data, isLoading: isQueryLoading, error } = useQuery({ const { data, isLoading: isQueryLoading, error } = useQuery({
queryKey: queryKeys.plugins.uiContributions(filters.companyId), queryKey: queryKeys.plugins.uiContributions,
queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined), queryFn: () => pluginsApi.listUiContributions(),
enabled: queryEnabled, enabled: queryEnabled,
}); });