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

@@ -140,14 +140,7 @@ export async function createApp(
const hostServicesDisposers = new Map<string, () => 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({

View File

@@ -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<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.
*
@@ -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<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
*
@@ -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<string, unknown>;
@@ -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<string, unknown>;
@@ -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",

View File

@@ -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<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
// ---------------------------------------------------------------------------
@@ -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<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.
*/
@@ -257,26 +173,7 @@ export function createPluginEventBus(options?: PluginEventBusOptions): PluginEve
const errors: Array<{ pluginId: string; error: unknown }> = [];
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) {
// 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;

View File

@@ -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 = <T extends { companyId: string | null | undefined }>(
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);

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
// -----------------------------------------------------------------------
@@ -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<PluginRecord> {
// 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 } =

View File

@@ -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<void>;
/**
* 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<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
// -----------------------------------------------------------------------
@@ -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));
}

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 {
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<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
// -----------------------------------------------------------------------
@@ -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<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
* against the plugin's instanceConfigSchema).

View File

@@ -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
// ---------------------------------------------------------------