Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/plugins/services/.server/PluginLifecycleService.ts
T

763 lines
30 KiB
TypeScript

/**
* Phase 06 #22 — Plugin lifecycle service.
*
* Owns install / enable / disable / list operations against the
* Plugin Prisma table created in #21. Each mutating call writes an
* audit-event row through the existing `createAuditEvent` helper so
* tenant admins have a tamper-evident trail of who installed or
* toggled what.
*
* Foundation scope:
* - install({ tenantId, pluginId, version, manifest, wasmSha256,
* signaturePublicKey, actor }): create row + audit.
* - enable / disable: flip Plugin.enabled + audit.
* - list({ tenantId }): page through installed plugins.
* - uninstall: delete row + audit (does NOT delete the .wasm binary
* — operator-only step, deferred to #23 marketplace work).
*
* Deferred (see hooks-lifecycle.md):
* - capability consent dialog flow (we trust the manifest's
* declared capabilities for the foundation).
* - per-tenant secret + settings tables.
* - hot-reload + side-by-side version updates.
*/
import { db } from "~/utils/db.server";
import {
isPluginCompatibleWithHost,
isCapabilityGrantScopeSatisfied,
parsePluginManifest,
parseTrustedPublicKeys,
verifyPluginSignature,
assertPluginRuntimeProductionEnabled,
getUnavailablePluginCapabilityNames,
getUnavailablePluginHookNames,
getPluginHook,
type PluginManifest,
type PluginVersionConstraints,
} from "@toolctrl/plugin-runtime";
import { createAuditEvent, publishAuditEventViaOutbox } from "@toolctrl/db/audit-events.server";
import { createScopedLogger } from "~/utils/logger.server";
import rootPackageJson from "../../../../../../../package.json" with { type: "json" };
import { getPluginConsentRequirements, isPluginHookConsentName } from "../../utils/pluginConsentRequirements";
const logger = createScopedLogger("plugin-lifecycle");
export { isCapabilityGrantScopeSatisfied };
/**
* Host version used by the install-time compat check (Phase 06 #23
* Step 12). Reads from `PLUGIN_HOST_VERSION_OVERRIDE` first so operators
* can pin a value during incident response without re-tagging the
* monorepo, falls back to `npm_package_version` (set by pnpm at run
* time), then to the literal version baked into `package.json` at
* build time.
*/
function resolveHostVersion(): string {
return process.env.PLUGIN_HOST_VERSION_OVERRIDE || process.env.npm_package_version || (rootPackageJson as { version: string }).version;
}
/**
* Pull `minHostVersion` / `maxHostVersion` off the raw manifest input.
* The Zod schema strips unknown keys via `.strict()`, so we read the
* fields off the original object before the parse step would discard
* them. Returns an empty object when the input is not a plain object
* or carries neither field.
*/
function extractVersionConstraints(raw: unknown): PluginVersionConstraints {
if (!raw || typeof raw !== "object") return {};
const obj = raw as Record<string, unknown>;
const out: PluginVersionConstraints = {};
if (typeof obj.minHostVersion === "string") out.minHostVersion = obj.minHostVersion;
if (typeof obj.maxHostVersion === "string") out.maxHostVersion = obj.maxHostVersion;
return out;
}
export class PluginLifecycleError extends Error {
public override readonly name = "PluginLifecycleError";
public readonly code:
| "manifest_invalid"
| "already_installed"
| "not_found"
| "incompatible_host_version"
| "signature_key_not_trusted"
| "signature_key_mismatch"
| "tenant_install_limit_exceeded"
| "binary_not_found"
| "binary_verification_failed"
| "capability_consent_missing"
| "production_guard_violation"
| "capability_unavailable"
| "hook_unavailable"
| "capability_not_in_manifest"
| "capability_scope_invalid";
constructor(code: PluginLifecycleError["code"], message: string) {
super(message);
this.code = code;
}
}
/**
* F-005 Phase B — install-time binary signature verification.
*
* Phase A (already shipped) gates installs on the trust-store and on
* `signedBy === signaturePublicKey`. Phase B closes the remaining gap:
* we now also read the .wasm binary from disk and run the same
* `verifyPluginSignature` the worker invokes per-call. If the binary
* doesn't exist, has the wrong sha256, or fails Ed25519 verification,
* we reject the install — the operator gets a synchronous, descriptive
* error instead of learning about the mismatch via auto-disable noise.
*
* The binary base path mirrors the worker bootstrap convention; the
* operator pre-positions the binary at
* <PLUGIN_BINARY_BASE_PATH>/<pluginId>/<version>/plugin.wasm
* before triggering the install API. Dev overrides via
* `PLUGIN_BINARY_BASE_PATH`; default `/app/var/plugins`.
*/
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
function isProductionRuntime() {
return process.env.NODE_ENV === "production";
}
function assertPluginProductionEscapeHatchesDisabled() {
if (!isProductionRuntime()) {
return;
}
if (process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY === "true") {
throw new PluginLifecycleError("production_guard_violation", "PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true is not allowed in production.");
}
if (process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT === "false") {
throw new PluginLifecycleError("production_guard_violation", "PLUGIN_REQUIRE_CAPABILITY_CONSENT=false is not allowed in production.");
}
}
function assertPluginProductionRuntimeEnabled(operation: string) {
try {
assertPluginRuntimeProductionEnabled();
} catch (error) {
throw new PluginLifecycleError("production_guard_violation", `${operation} blocked: ${error instanceof Error ? error.message : String(error)}`);
}
}
function assertProductionCapabilitiesAvailable(operation: string, capabilities: ReadonlyArray<PluginManifest["capabilities"][number] | string>) {
if (!isProductionRuntime()) {
return;
}
const unavailable = getUnavailablePluginCapabilityNames(capabilities);
if (unavailable.length === 0) {
return;
}
throw new PluginLifecycleError(
"capability_unavailable",
`${operation} blocked: capabilities [${unavailable.join(", ")}] do not have reviewed production host bindings yet.`
);
}
function assertProductionHooksAvailable(operation: string, hooks: ReadonlyArray<PluginManifest["hooks"][number] | string>) {
if (!isProductionRuntime()) {
return;
}
const unavailable = getUnavailablePluginHookNames(hooks);
if (unavailable.length === 0) {
return;
}
throw new PluginLifecycleError(
"hook_unavailable",
`${operation} blocked: hooks [${unavailable.join(", ")}] are registered but not reviewed for production availability yet.`
);
}
function assertManifestHooksRegistered(manifest: PluginManifest) {
const unknownHook = manifest.hooks.find((hook) => !getPluginHook(hook.name));
if (unknownHook) {
throw new PluginLifecycleError(
"manifest_invalid",
`Plugin ${manifest.id}@${manifest.version} declares unknown hook '${unknownHook.name}'. Register the hook in the host runtime before installing.`
);
}
}
function parseStoredPluginManifest(manifestJson: unknown, context: { pluginId?: string; version?: string }): PluginManifest {
const parsed = parsePluginManifest(manifestJson);
if (parsed.ok) return parsed.manifest;
const label = context.pluginId && context.version ? ` for ${context.pluginId}@${context.version}` : "";
throw new PluginLifecycleError("manifest_invalid", `Stored plugin manifest${label} failed validation: ${JSON.stringify(parsed.issues)}`);
}
async function readPluginBinary(args: { basePath: string; pluginId: string; version: string }): Promise<Uint8Array | null> {
const { promises: fs } = await import("node:fs");
const path = await import("node:path");
const filePath = path.join(args.basePath, args.pluginId, args.version, "plugin.wasm");
try {
const buf = await fs.readFile(filePath);
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}
/**
* F-009 — per-tenant install cap. Without this, a tenant admin can
* install thousands of plugin rows to bloat the Plugin table and the
* hook fan-out path. The default is conservative (50); operators
* override via `PLUGIN_MAX_INSTALLS_PER_TENANT`. A non-positive override
* disables the cap so dev environments can opt out.
*/
const DEFAULT_MAX_INSTALLS_PER_TENANT = 50;
function resolveMaxInstallsPerTenant(): number {
const raw = process.env.PLUGIN_MAX_INSTALLS_PER_TENANT;
if (typeof raw !== "string" || raw.length === 0) return DEFAULT_MAX_INSTALLS_PER_TENANT;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return DEFAULT_MAX_INSTALLS_PER_TENANT;
if (parsed <= 0) return Number.POSITIVE_INFINITY; // 0 or negative disables the cap
return parsed;
}
export type PluginLifecycleActor = {
userId: string | null;
email: string | null;
ip: string | null;
};
export type InstallPluginInput = {
tenantId: string;
manifestJson: unknown;
wasmSha256: string;
signaturePublicKey: string;
actor: PluginLifecycleActor;
};
export type PluginListItem = {
id: string;
pluginId: string;
version: string;
enabled: boolean;
manifest: PluginManifest;
installedAt: Date;
updatedAt: Date;
};
export async function installPlugin(input: InstallPluginInput): Promise<PluginListItem> {
assertPluginProductionRuntimeEnabled("Plugin install");
assertPluginProductionEscapeHatchesDisabled();
const parsed = parsePluginManifest(input.manifestJson);
if (!parsed.ok) {
throw new PluginLifecycleError("manifest_invalid", `Manifest validation failed: ${JSON.stringify(parsed.issues)}`);
}
const manifest = parsed.manifest;
assertManifestHooksRegistered(manifest);
assertProductionHooksAvailable("Plugin install", manifest.hooks ?? []);
assertProductionCapabilitiesAvailable("Plugin install", manifest.capabilities ?? []);
// Host-version compatibility check (Phase 06 #23 Step 12). The Zod
// schema does not yet carry `minHostVersion` / `maxHostVersion` —
// they ride on the raw manifest object when the plugin author opts
// in, and we read them off the original input so the strict-parse
// step does not strip them before the check runs.
const rawConstraints = extractVersionConstraints(input.manifestJson);
const hostVersion = resolveHostVersion();
const compat = isPluginCompatibleWithHost({ ...manifest, ...rawConstraints }, hostVersion);
if (!compat.compatible) {
throw new PluginLifecycleError("incompatible_host_version", `Plugin ${manifest.id}@${manifest.version} rejected: ${compat.reason} — ${compat.details}`);
}
// F-005 Phase A — install-time trust-store gate. The worker re-verifies
// the binary signature at every invocation, but we should not let a
// tenant admin pin a Plugin row to a publisher key the operator has
// not trusted: doing so pollutes the table with rows that will always
// fail to invoke and could mislead the audit trail. We also require
// that the submitted `signaturePublicKey` matches the manifest's
// `signedBy` field — anything else means the caller sent inconsistent
// metadata and we reject up front.
//
// Phase B (full binary signature verify on install — i.e. running
// `verifyPluginSignature` against the actual .wasm on disk before
// creating the row) still depends on the operator-side binary upload
// pipeline and is tracked in plugin-internal-findings.md F-005.
const submittedKey = input.signaturePublicKey.toLowerCase();
if (manifest.signedBy.toLowerCase() !== submittedKey) {
throw new PluginLifecycleError("signature_key_mismatch", `signaturePublicKey does not match manifest.signedBy for plugin ${manifest.id}.`);
}
const trustedKeys = parseTrustedPublicKeys(process.env.PLUGIN_TRUSTED_PUBLIC_KEYS);
if (!trustedKeys.includes(submittedKey)) {
throw new PluginLifecycleError(
"signature_key_not_trusted",
`Publisher key ${submittedKey.slice(0, 16)}… is not in PLUGIN_TRUSTED_PUBLIC_KEYS — install rejected.`
);
}
// F-005 Phase B — verify the .wasm binary against the manifest BEFORE
// creating the Plugin row. This catches sha256/signature mismatches at
// install time instead of letting the worker discover them at invoke
// (where they would surface as repeated `signature_invalid` failures
// and trip the crash tracker).
//
// Skipped intentionally when PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true so
// dev environments without binaries on disk can still run integration
// tests. The flag must NOT be set in production — documented in the
// operator runbook.
if (process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY !== "true") {
const basePath = process.env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
const binary = await readPluginBinary({
basePath,
pluginId: manifest.id,
version: manifest.version,
});
if (!binary) {
throw new PluginLifecycleError(
"binary_not_found",
`Plugin binary for ${manifest.id}@${manifest.version} not found at ${basePath}. ` +
"Operator must pre-position the .wasm file before triggering install."
);
}
const verifyResult = verifyPluginSignature({
wasmBuffer: binary,
expectedWasmSha256: input.wasmSha256,
signatureHex: manifest.signature,
publicKeyHex: manifest.signedBy,
trustedPublicKeysHex: trustedKeys,
manifest,
});
if (!verifyResult.ok) {
throw new PluginLifecycleError(
"binary_verification_failed",
`Plugin binary verification failed for ${manifest.id}@${manifest.version}: ${verifyResult.reason}.`
);
}
// Cross-check submitted wasmSha256 matches manifest.wasmSha256.
if (input.wasmSha256.toLowerCase() !== manifest.wasmSha256.toLowerCase()) {
throw new PluginLifecycleError(
"binary_verification_failed",
`Submitted wasmSha256 does not match manifest.wasmSha256 for ${manifest.id}@${manifest.version}.`
);
}
}
const existing = await db.plugin.findUnique({
where: { tenantId_pluginId: { tenantId: input.tenantId, pluginId: manifest.id } },
});
if (existing) {
throw new PluginLifecycleError("already_installed", `Plugin ${manifest.id} is already installed for tenant ${input.tenantId}.`);
}
// F-009 — enforce per-tenant install cap. We count only AFTER all
// cheaper rejections so an over-limit tenant still gets the most
// useful error (e.g. signature_key_not_trusted) when they submit
// garbage. The count + create are not transactional — racy double-
// install can squeak by once at the boundary, but the unique
// (tenantId, pluginId) index above rejects exact-duplicate ids, so
// the worst case is a tenant landing at `cap + 1` distinct plugins.
const maxInstalls = resolveMaxInstallsPerTenant();
if (Number.isFinite(maxInstalls)) {
const installed = await db.plugin.count({ where: { tenantId: input.tenantId } });
if (installed >= maxInstalls) {
throw new PluginLifecycleError(
"tenant_install_limit_exceeded",
`Tenant ${input.tenantId} has reached the install cap (${installed}/${maxInstalls}). ` +
"Uninstall an existing plugin or raise PLUGIN_MAX_INSTALLS_PER_TENANT."
);
}
}
const row = await db.plugin.create({
data: {
tenantId: input.tenantId,
pluginId: manifest.id,
version: manifest.version,
manifest: manifest as unknown as Parameters<typeof db.plugin.create>[0]["data"]["manifest"],
wasmSha256: input.wasmSha256,
signaturePublicKey: input.signaturePublicKey,
enabled: false,
},
});
await createAuditEvent({
tenantId: input.tenantId,
actorId: input.actor.userId,
actorType: input.actor.userId ? "user" : "system",
actorEmail: input.actor.email,
actorIp: input.actor.ip,
action: "plugin.installed",
targetType: "plugin",
targetId: row.id,
sourceModule: "plugin-lifecycle",
outcome: "success",
dataSensitivity: "internal",
metadata: { pluginId: manifest.id, version: manifest.version },
});
logger.info({ tenantId: input.tenantId, pluginId: manifest.id, version: manifest.version }, "plugin.installed");
return toListItem(row);
}
export async function enablePlugin(args: { tenantId: string; pluginRowId: string; actor: PluginLifecycleActor }): Promise<PluginListItem> {
assertPluginProductionRuntimeEnabled("Plugin enable");
return await setEnabled({ ...args, enabled: true });
}
export async function disablePlugin(args: { tenantId: string; pluginRowId: string; actor: PluginLifecycleActor }): Promise<PluginListItem> {
return await setEnabled({ ...args, enabled: false });
}
/**
* F-001 — Capability grant types + service.
*
* `grantPluginCapabilities` upserts (tenantId, pluginId, capabilityName)
* rows. When the operator approves all manifest-declared capabilities,
* `enablePlugin` is then allowed to flip the Plugin row to enabled.
* Missing grants raise `capability_consent_missing`.
*
* Disabling never requires grants — operators must always be able to
* stop a plugin.
*/
export type CapabilityGrant = {
capabilityName: string;
scopeJson?: unknown;
};
export type CapabilityRevokeResult = {
revokedCapabilities: string[];
autoDisabled: boolean;
};
export type PluginCapabilityGrantRow = {
id: string;
tenantId: string;
pluginId: string;
capabilityName: string;
scopeJson: unknown;
grantedAt: Date;
grantedByUserId: string | null;
};
export async function listPluginCapabilityGrants(args: { tenantId: string; pluginId: string }): Promise<PluginCapabilityGrantRow[]> {
return await listPluginCapabilityGrantsWithClient(db, args);
}
async function listPluginCapabilityGrantsWithClient(client: unknown, args: { tenantId: string; pluginId: string }): Promise<PluginCapabilityGrantRow[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tbl = (client as any).tenantPluginCapabilityGrant;
if (!tbl) return [];
const rows = await tbl.findMany({
where: { tenantId: args.tenantId, pluginId: args.pluginId },
orderBy: { grantedAt: "desc" },
});
return rows;
}
export async function listPluginCapabilityGrantsForPlugins(args: { tenantId: string; pluginIds: ReadonlyArray<string> }): Promise<PluginCapabilityGrantRow[]> {
const pluginIds = [...new Set(args.pluginIds)].filter((pluginId) => pluginId.length > 0);
if (pluginIds.length === 0) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tbl = (db as any).tenantPluginCapabilityGrant;
if (!tbl) return [];
const rows = await tbl.findMany({
where: { tenantId: args.tenantId, pluginId: { in: pluginIds } },
orderBy: { grantedAt: "desc" },
});
return rows;
}
export async function grantPluginCapabilities(args: {
tenantId: string;
pluginId: string;
grants: ReadonlyArray<CapabilityGrant>;
actor: PluginLifecycleActor;
capabilityReviewDigest?: string;
}): Promise<PluginCapabilityGrantRow[]> {
assertPluginProductionRuntimeEnabled("Plugin capability grant");
// Validate every requested grant is actually declared in the
// installed plugin's manifest — operators cannot grant capabilities
// the plugin never asked for.
const installed = await db.plugin.findUnique({
where: { tenantId_pluginId: { tenantId: args.tenantId, pluginId: args.pluginId } },
});
if (!installed) {
throw new PluginLifecycleError("not_found", `Plugin ${args.pluginId} not installed for tenant ${args.tenantId}.`);
}
const manifest = parseStoredPluginManifest(installed.manifest, { pluginId: installed.pluginId, version: installed.version });
assertManifestHooksRegistered(manifest);
assertProductionHooksAvailable(
"Plugin capability grant",
args.grants
.map((grant) => grant.capabilityName)
.filter(isPluginHookConsentName)
.map((name) => name.slice("hook:".length))
);
const declared = new Map(getPluginConsentRequirements(manifest).map((requirement) => [requirement.name, requirement]));
assertProductionCapabilitiesAvailable(
"Plugin capability grant",
args.grants.map((grant) => grant.capabilityName).filter((name) => !isPluginHookConsentName(name))
);
for (const grant of args.grants) {
const declaredRequirement = declared.get(grant.capabilityName);
if (!declaredRequirement) {
throw new PluginLifecycleError("capability_not_in_manifest", `Consent item '${grant.capabilityName}' is not declared in the plugin manifest.`);
}
if (
!isCapabilityGrantScopeSatisfied({
declaredScope: declaredRequirement.scope ?? null,
grantedScopeJson: grant.scopeJson ?? null,
})
) {
throw new PluginLifecycleError("capability_scope_invalid", `Consent item '${grant.capabilityName}' grant scope does not match the plugin manifest.`);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tbl = (db as any).tenantPluginCapabilityGrant;
if (!tbl) {
throw new PluginLifecycleError("not_found", "TenantPluginCapabilityGrant table not migrated; run prisma migrate deploy.");
}
const auditGrants = args.grants
.map((grant) => ({
capabilityName: grant.capabilityName,
scopeJson: grant.scopeJson ?? null,
}))
.sort((left, right) => left.capabilityName.localeCompare(right.capabilityName));
const manifestCapabilitySnapshot = auditGrants.map((grant) => {
const declaredCapability = declared.get(grant.capabilityName);
return {
capabilityName: grant.capabilityName,
declaredScope: declaredCapability?.scope ?? null,
kind: declaredCapability?.kind ?? "capability",
};
});
const rows = await db.$transaction(async (tx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const txTbl = (tx as any).tenantPluginCapabilityGrant;
const grantedRows: PluginCapabilityGrantRow[] = [];
for (const grant of args.grants) {
const row = await txTbl.upsert({
where: {
tenantId_pluginId_capabilityName: {
tenantId: args.tenantId,
pluginId: args.pluginId,
capabilityName: grant.capabilityName,
},
},
create: {
tenantId: args.tenantId,
pluginId: args.pluginId,
capabilityName: grant.capabilityName,
scopeJson: grant.scopeJson as never,
grantedByUserId: args.actor.userId,
},
update: {
scopeJson: grant.scopeJson as never,
grantedByUserId: args.actor.userId,
grantedAt: new Date(),
},
});
grantedRows.push(row);
}
await publishAuditEventViaOutbox(tx, {
tenantId: args.tenantId,
actorId: args.actor.userId,
actorType: args.actor.userId ? "user" : "system",
actorEmail: args.actor.email,
actorIp: args.actor.ip,
action: "plugin.capabilities.granted",
targetType: "plugin",
targetId: installed.id,
sourceModule: "plugin-lifecycle",
outcome: "success",
dataSensitivity: "internal",
metadata: {
pluginId: args.pluginId,
version: installed.version,
capabilityReviewDigest: args.capabilityReviewDigest ?? null,
grantedCapabilities: auditGrants.map((grant) => grant.capabilityName),
grants: auditGrants,
manifestCapabilities: manifestCapabilitySnapshot,
},
});
return grantedRows;
});
logger.info({ tenantId: args.tenantId, pluginId: args.pluginId, count: args.grants.length }, "plugin.capabilities.granted");
return rows;
}
export async function revokePluginCapabilities(args: {
tenantId: string;
pluginId: string;
capabilityNames: ReadonlyArray<string>;
actor: PluginLifecycleActor;
}): Promise<CapabilityRevokeResult> {
const capabilityNames = Array.from(new Set(args.capabilityNames.filter((name) => typeof name === "string" && name.length > 0)));
if (capabilityNames.length === 0) {
throw new PluginLifecycleError("capability_not_in_manifest", "At least one plugin capability must be selected for revocation.");
}
const installed = await db.plugin.findUnique({
where: { tenantId_pluginId: { tenantId: args.tenantId, pluginId: args.pluginId } },
});
if (!installed) {
throw new PluginLifecycleError("not_found", `Plugin ${args.pluginId} not installed for tenant ${args.tenantId}.`);
}
const manifest = parseStoredPluginManifest(installed.manifest, { pluginId: installed.pluginId, version: installed.version });
const declared = new Set(getPluginConsentRequirements(manifest).map((requirement) => requirement.name));
const unknownCapability = capabilityNames.find((capabilityName) => !declared.has(capabilityName));
if (unknownCapability) {
throw new PluginLifecycleError("capability_not_in_manifest", `Consent item '${unknownCapability}' is not declared in the plugin manifest.`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tbl = (db as any).tenantPluginCapabilityGrant;
if (!tbl) {
throw new PluginLifecycleError("not_found", "TenantPluginCapabilityGrant table not migrated; run prisma migrate deploy.");
}
const autoDisabled = Boolean(installed.enabled);
await db.$transaction(async (tx) => {
if (autoDisabled) {
await tx.plugin.update({
where: { id: installed.id },
data: { enabled: false },
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const txTbl = (tx as any).tenantPluginCapabilityGrant;
await txTbl.deleteMany({
where: {
tenantId: args.tenantId,
pluginId: args.pluginId,
capabilityName: { in: capabilityNames },
},
});
await publishAuditEventViaOutbox(tx, {
tenantId: args.tenantId,
actorId: args.actor.userId,
actorType: args.actor.userId ? "user" : "system",
actorEmail: args.actor.email,
actorIp: args.actor.ip,
action: "plugin.capabilities.revoked",
targetType: "plugin",
targetId: installed.id,
sourceModule: "plugin-lifecycle",
outcome: "success",
dataSensitivity: "internal",
metadata: {
pluginId: args.pluginId,
revokedCapabilities: capabilityNames,
autoDisabled,
},
});
});
logger.info({ tenantId: args.tenantId, pluginId: args.pluginId, count: capabilityNames.length, autoDisabled }, "plugin.capabilities.revoked");
return { revokedCapabilities: capabilityNames, autoDisabled };
}
async function assertConsentSatisfied(args: { tenantId: string; pluginId: string; manifest: PluginManifest; client?: unknown }): Promise<void> {
const declared = getPluginConsentRequirements(args.manifest);
if (declared.length === 0) return; // nothing to consent to
const grants = await listPluginCapabilityGrantsWithClient(args.client ?? db, {
tenantId: args.tenantId,
pluginId: args.pluginId,
});
const grantByCapability = new Map(grants.map((g) => [g.capabilityName, g]));
const missing = declared
.filter((capability) => {
const grant = grantByCapability.get(capability.name);
return (
!grant ||
!isCapabilityGrantScopeSatisfied({
declaredScope: capability.scope ?? null,
grantedScopeJson: grant.scopeJson,
})
);
})
.map((requirement) => requirement.name);
if (missing.length > 0) {
throw new PluginLifecycleError(
"capability_consent_missing",
`Plugin ${args.pluginId} cannot be enabled: missing operator consent for [${missing.join(", ")}].`
);
}
}
async function setEnabled(args: { tenantId: string; pluginRowId: string; enabled: boolean; actor: PluginLifecycleActor }): Promise<PluginListItem> {
assertPluginProductionEscapeHatchesDisabled();
const row = await db.$transaction(
async (tx) => {
const existing = await tx.plugin.findFirst({
where: { id: args.pluginRowId, tenantId: args.tenantId },
});
if (!existing) {
throw new PluginLifecycleError("not_found", `Plugin row ${args.pluginRowId} not found for tenant ${args.tenantId}.`);
}
const manifest = parseStoredPluginManifest(existing.manifest, { pluginId: existing.pluginId, version: existing.version });
// F-001 — enable requires consent for every manifest-declared
// capability. Disable is always allowed (kill-switch parity).
if (args.enabled) {
assertManifestHooksRegistered(manifest);
assertProductionHooksAvailable("Plugin enable", manifest.hooks ?? []);
if (process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT !== "false") {
assertProductionCapabilitiesAvailable("Plugin enable", manifest.capabilities ?? []);
await assertConsentSatisfied({
tenantId: args.tenantId,
pluginId: existing.pluginId,
manifest,
client: tx,
});
}
}
const updated = await tx.plugin.update({
where: { id: existing.id },
data: { enabled: args.enabled },
});
await publishAuditEventViaOutbox(tx, {
tenantId: args.tenantId,
actorId: args.actor.userId,
actorType: args.actor.userId ? "user" : "system",
actorEmail: args.actor.email,
actorIp: args.actor.ip,
action: args.enabled ? "plugin.enabled" : "plugin.disabled",
targetType: "plugin",
targetId: updated.id,
sourceModule: "plugin-lifecycle",
outcome: "success",
dataSensitivity: "internal",
metadata: { pluginId: updated.pluginId, version: updated.version },
});
return updated;
},
{ isolationLevel: "Serializable" }
);
logger.info({ tenantId: args.tenantId, pluginId: row.pluginId, enabled: args.enabled }, args.enabled ? "plugin.enabled" : "plugin.disabled");
return toListItem(row);
}
export async function listPlugins(args: { tenantId: string }): Promise<PluginListItem[]> {
const rows = await db.plugin.findMany({
where: { tenantId: args.tenantId },
orderBy: { installedAt: "desc" },
});
return rows.map(toListItem);
}
export async function getPluginForTenant(args: { tenantId: string; pluginRowId: string }): Promise<PluginListItem | null> {
const row = await db.plugin.findFirst({
where: { id: args.pluginRowId, tenantId: args.tenantId },
});
return row ? toListItem(row) : null;
}
type PluginRow = Awaited<ReturnType<typeof db.plugin.findFirstOrThrow>>;
function toListItem(row: PluginRow): PluginListItem {
const manifest = parseStoredPluginManifest(row.manifest, { pluginId: row.pluginId, version: row.version });
return {
id: row.id,
pluginId: row.pluginId,
version: row.version,
enabled: row.enabled,
manifest,
installedAt: row.installedAt,
updatedAt: row.updatedAt,
};
}