Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,528 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PLUGIN_SETTING_ARRAY_MAX_LENGTH,
PLUGIN_SETTING_KEY_MAX_LENGTH,
PLUGIN_SETTING_OBJECT_MAX_KEYS,
PLUGIN_SETTING_VALUE_MAX_BYTES,
PLUGIN_SETTING_VALUE_MAX_DEPTH,
PLUGIN_SETTING_VALUE_MAX_NODES,
isPluginSettingValue,
isSafePluginSettingKey,
type PluginSettingValue,
} from "../utils/pluginSettingsValidation";
export const PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION = 1;
export const PLUGIN_SETTINGS_FRAME_MAX_PLUGIN_ID_LENGTH = 256;
export const PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH = 128;
export const PLUGIN_SETTINGS_FRAME_MAX_KEY_LENGTH = PLUGIN_SETTING_KEY_MAX_LENGTH;
export const PLUGIN_SETTINGS_FRAME_MAX_VALUE_BYTES = PLUGIN_SETTING_VALUE_MAX_BYTES;
export const PLUGIN_SETTINGS_FRAME_MAX_VALUE_DEPTH = PLUGIN_SETTING_VALUE_MAX_DEPTH;
export const PLUGIN_SETTINGS_FRAME_MAX_VALUE_NODES = PLUGIN_SETTING_VALUE_MAX_NODES;
export const PLUGIN_SETTINGS_FRAME_MAX_ARRAY_LENGTH = PLUGIN_SETTING_ARRAY_MAX_LENGTH;
export const PLUGIN_SETTINGS_FRAME_MAX_OBJECT_KEYS = PLUGIN_SETTING_OBJECT_MAX_KEYS;
export const PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS = 32;
// Window during which a second `load` for the same `srcdoc` document is treated
// as a benign Chromium duplicate rather than a navigation. A real plugin-driven
// self-navigation happens well after this window (it requires user/script
// interaction), so security-relevant navigations are still quarantined.
export const PLUGIN_SETTINGS_FRAME_LOAD_COALESCE_MS = 1_000;
const PLUGIN_SETTINGS_FRAME_PROTOCOL_STRING_PATTERN = /^[A-Za-z0-9._:-]+$/u;
export const PLUGIN_SETTINGS_FRAME_ALLOW = [
"accelerometer 'none'",
"ambient-light-sensor 'none'",
"attribution-reporting 'none'",
"autoplay 'none'",
"bluetooth 'none'",
"browsing-topics 'none'",
"camera 'none'",
"clipboard-read 'none'",
"clipboard-write 'none'",
"display-capture 'none'",
"encrypted-media 'none'",
"fullscreen 'none'",
"gamepad 'none'",
"geolocation 'none'",
"gyroscope 'none'",
"hid 'none'",
"identity-credentials-get 'none'",
"idle-detection 'none'",
"local-fonts 'none'",
"magnetometer 'none'",
"microphone 'none'",
"midi 'none'",
"otp-credentials 'none'",
"payment 'none'",
"picture-in-picture 'none'",
"publickey-credentials-get 'none'",
"screen-wake-lock 'none'",
"serial 'none'",
"speaker-selection 'none'",
"storage-access 'none'",
"sync-xhr 'none'",
"usb 'none'",
"web-share 'none'",
"xr-spatial-tracking 'none'",
].join("; ");
export const PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME = "plugin-settings-channel";
export const PLUGIN_SETTINGS_FRAME_SANDBOX = "allow-scripts";
export const PLUGIN_SETTINGS_FRAME_CSP_NONCE_PLACEHOLDER = "__PLUGIN_SETTINGS_FRAME_SCRIPT_NONCE__";
export const PLUGIN_SETTINGS_FRAME_CSP = buildPluginSettingsFrameCsp(PLUGIN_SETTINGS_FRAME_CSP_NONCE_PLACEHOLDER);
export function buildPluginSettingsFrameCsp(scriptNonce: string) {
assertSafePluginSettingsFrameScriptNonce(scriptNonce);
return [
"default-src 'none'",
`script-src 'nonce-${scriptNonce}'`,
"script-src-attr 'none'",
// Self-contained signed settings HTML may use inline style attributes.
"style-src 'unsafe-inline'",
"img-src data:",
"font-src data:",
"connect-src 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"media-src 'none'",
"object-src 'none'",
"form-action 'none'",
"base-uri 'none'",
"navigate-to 'none'",
].join("; ");
}
type PluginSettingsReadyMessage = {
readonly type: "plugin.settings.ready";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId?: string;
};
type PluginSettingsGetMessage = {
readonly type: "plugin.settings.get";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly key: string;
};
type PluginSettingsSetMessage = {
readonly type: "plugin.settings.set";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly key: string;
readonly value: JsonLikeValue;
};
export type PluginSettingsFrameMessage = PluginSettingsReadyMessage | PluginSettingsGetMessage | PluginSettingsSetMessage;
export type PluginSettingsFrameResultMessage =
| {
readonly type: "plugin.settings.result";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly ok: true;
readonly key?: string;
readonly value?: JsonLikeValue;
}
| {
readonly type: "plugin.settings.result";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly ok: false;
readonly error: "settings_unavailable" | "settings_update_failed" | "invalid_payload";
readonly message: string;
};
type PluginSettingsFrameResultError = Extract<PluginSettingsFrameResultMessage, { readonly ok: false }>["error"];
type PluginSettingsFrameErrorMessages = Record<PluginSettingsFrameResultError, string>;
export const PLUGIN_SETTINGS_FRAME_DEFAULT_ERROR_MESSAGES: PluginSettingsFrameErrorMessages = {
invalid_payload: "The plugin settings request is invalid.",
settings_unavailable: "This setting is unavailable.",
settings_update_failed: "Plugin settings could not be saved.",
};
type JsonLikeValue = PluginSettingValue;
interface PluginSettingsGetHandler {
(key: string): JsonLikeValue | Promise<JsonLikeValue>;
}
interface PluginSettingsSetHandler {
(key: string, value: JsonLikeValue): void | Promise<void>;
}
export type PluginSettingsFrameProps = {
readonly pluginId: string;
readonly title: string;
readonly trustedHtml: string;
readonly settings?: Readonly<Record<string, JsonLikeValue>>;
readonly className?: string;
readonly dataUiId?: string;
readonly onReady?: (message: PluginSettingsReadyMessage) => void;
readonly onGetSetting?: PluginSettingsGetHandler;
readonly onSetSetting?: PluginSettingsSetHandler;
readonly errorMessages?: Partial<PluginSettingsFrameErrorMessages>;
readonly onInvalidMessage?: (reason: "wrong_origin" | "wrong_source" | "invalid_payload" | "unexpected_navigation") => void;
};
export function PluginSettingsFrame({
pluginId,
title,
trustedHtml,
settings,
className,
dataUiId,
onReady,
onGetSetting,
onSetSetting,
errorMessages,
onInvalidMessage,
}: PluginSettingsFrameProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const initialLoadSeenRef = useRef(false);
const initialLoadAtRef = useRef(0);
const initialLoadWindowRef = useRef<Window | null>(null);
const inFlightRequestIdsRef = useRef<Set<string>>(new Set());
const quarantinedFrameWindowRef = useRef<Window | null>(null);
const [frameInstance, setFrameInstance] = useState(0);
const [channelId, setChannelId] = useState(createPluginSettingsFrameChannelId);
const [scriptNonce, setScriptNonce] = useState(createPluginSettingsFrameScriptNonce);
const resolvedErrorMessages = useMemo(() => resolvePluginSettingsFrameErrorMessages(errorMessages), [errorMessages]);
const settingsRef = useRef(settings);
const onReadyRef = useRef(onReady);
const onGetSettingRef = useRef(onGetSetting);
const onSetSettingRef = useRef(onSetSetting);
const onInvalidMessageRef = useRef(onInvalidMessage);
const resolvedErrorMessagesRef = useRef(resolvedErrorMessages);
settingsRef.current = settings;
onReadyRef.current = onReady;
onGetSettingRef.current = onGetSetting;
onSetSettingRef.current = onSetSetting;
onInvalidMessageRef.current = onInvalidMessage;
resolvedErrorMessagesRef.current = resolvedErrorMessages;
const srcDoc = useMemo(
() => buildPluginSettingsFrameSrcDoc({ pluginId, trustedHtml, channelId, scriptNonce }),
[channelId, pluginId, scriptNonce, trustedHtml]
);
const lastSrcDocRef = useRef(srcDoc);
if (lastSrcDocRef.current !== srcDoc) {
lastSrcDocRef.current = srcDoc;
initialLoadSeenRef.current = false;
initialLoadAtRef.current = 0;
initialLoadWindowRef.current = null;
inFlightRequestIdsRef.current.clear();
quarantinedFrameWindowRef.current = null;
}
const handleFrameLoad = useCallback(() => {
const loadedWindow = iframeRef.current?.contentWindow ?? null;
if (!initialLoadSeenRef.current) {
initialLoadSeenRef.current = true;
initialLoadAtRef.current = Date.now();
initialLoadWindowRef.current = loadedWindow;
return;
}
// Chromium can emit a spurious extra `load` for the same `srcdoc` document
// (e.g. under layout/CPU pressure) shortly after the real initial load.
// That duplicate keeps the same `contentWindow` and arrives within a short
// coalescing window. Rotating the channel for it would silently invalidate
// the live channelId mid-interaction (the iframe still references the old
// one), so we ignore it. A genuine self-navigation discards the document and
// always happens later than this window after user/script interaction, so it
// still triggers quarantine below.
const isSameWindowDuplicate = loadedWindow !== null && loadedWindow === initialLoadWindowRef.current;
const isWithinCoalesceWindow = Date.now() - initialLoadAtRef.current <= PLUGIN_SETTINGS_FRAME_LOAD_COALESCE_MS;
if (isSameWindowDuplicate && isWithinCoalesceWindow) {
return;
}
quarantinedFrameWindowRef.current = loadedWindow;
initialLoadSeenRef.current = false;
initialLoadAtRef.current = 0;
initialLoadWindowRef.current = null;
inFlightRequestIdsRef.current.clear();
onInvalidMessage?.("unexpected_navigation");
setChannelId(createPluginSettingsFrameChannelId());
setScriptNonce(createPluginSettingsFrameScriptNonce());
setFrameInstance((value) => value + 1);
}, [onInvalidMessage]);
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
const frameWindow = iframeRef.current?.contentWindow;
if (quarantinedFrameWindowRef.current && event.source === quarantinedFrameWindowRef.current) {
onInvalidMessageRef.current?.("wrong_source");
return;
}
if (!frameWindow || event.source !== frameWindow) {
if (isPluginSettingsFrameProtocolEnvelope(event.data, pluginId)) {
onInvalidMessageRef.current?.("wrong_source");
}
return;
}
if (event.origin !== "null") {
onInvalidMessageRef.current?.("wrong_origin");
return;
}
const message = parsePluginSettingsFrameMessage(event.data, pluginId);
if (!message || message.channelId !== channelId) {
const requestId = extractPluginSettingsFrameRequestId(event.data);
if (requestId) {
postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(requestId, "invalid_payload", resolvedErrorMessagesRef.current));
}
onInvalidMessageRef.current?.("invalid_payload");
return;
}
if (message.type === "plugin.settings.ready") {
onReadyRef.current?.(message);
return;
}
const inFlightRequestIds = inFlightRequestIdsRef.current;
if (inFlightRequestIds.has(message.requestId) || inFlightRequestIds.size >= PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS) {
postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "invalid_payload", resolvedErrorMessagesRef.current));
onInvalidMessageRef.current?.("invalid_payload");
return;
}
inFlightRequestIds.add(message.requestId);
try {
if (message.type === "plugin.settings.get") {
try {
const onGetSettingCurrent = onGetSettingRef.current;
const value = onGetSettingCurrent ? await onGetSettingCurrent(message.key) : settingsRef.current?.[message.key];
if (value === undefined || !isPluginSettingValue(value)) {
postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "settings_unavailable", resolvedErrorMessagesRef.current));
return;
}
postFrameResult(frameWindow, pluginId, channelId, { requestId: message.requestId, ok: true, key: message.key, value });
} catch {
postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "settings_unavailable", resolvedErrorMessagesRef.current));
}
return;
}
try {
const onSetSettingCurrent = onSetSettingRef.current;
if (!onSetSettingCurrent) {
postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "settings_update_failed", resolvedErrorMessagesRef.current));
return;
}
await onSetSettingCurrent(message.key, message.value);
postFrameResult(frameWindow, pluginId, channelId, { requestId: message.requestId, ok: true, key: message.key, value: message.value });
} catch (error) {
postFrameResult(
frameWindow,
pluginId,
channelId,
buildFrameErrorResult(message.requestId, "settings_update_failed", resolvedErrorMessagesRef.current, getPluginSettingsFrameErrorMessage(error))
);
}
} finally {
inFlightRequestIds.delete(message.requestId);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [channelId, pluginId]);
return (
<iframe
key={frameInstance}
ref={iframeRef}
title={title}
className={className}
data-ui-id={dataUiId ?? "ui.plugins.settings.frame"}
data-plugin-id={pluginId}
allow={PLUGIN_SETTINGS_FRAME_ALLOW}
referrerPolicy="no-referrer"
onLoad={handleFrameLoad}
sandbox={PLUGIN_SETTINGS_FRAME_SANDBOX}
suppressHydrationWarning
srcDoc={srcDoc}
/>
);
}
export function buildPluginSettingsFrameSrcDoc({
pluginId,
trustedHtml,
channelId,
scriptNonce = createPluginSettingsFrameScriptNonce(),
}: {
pluginId: string;
trustedHtml: string;
channelId: string;
scriptNonce?: string;
}) {
return [
"<!doctype html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(buildPluginSettingsFrameCsp(scriptNonce))}">`,
`<meta name="plugin-id" content="${escapeAttribute(pluginId)}">`,
`<meta name="plugin-settings-protocol" content="${PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION}">`,
`<meta name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}" content="${escapeAttribute(channelId)}">`,
'<meta name="viewport" content="width=device-width, initial-scale=1">',
"</head>",
"<body>",
addPluginSettingsFrameScriptNonce(trustedHtml, scriptNonce),
"</body>",
"</html>",
].join("");
}
export function addPluginSettingsFrameScriptNonce(trustedHtml: string, scriptNonce: string) {
return trustedHtml.replace(/<script\b([^>]*)>/giu, (match, rawAttributes: string) => {
const attributes = typeof rawAttributes === "string" ? rawAttributes : "";
if (hasHtmlAttribute(attributes, "src")) {
return match;
}
if (hasHtmlAttribute(attributes, "nonce")) {
return `<script${attributes.replace(/\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/iu, ` nonce="${escapeAttribute(scriptNonce)}"`)}>`;
}
return `<script nonce="${escapeAttribute(scriptNonce)}"${attributes}>`;
});
}
export function parsePluginSettingsFrameMessage(data: unknown, expectedPluginId?: string): PluginSettingsFrameMessage | null {
if (!isRecord(data) || data.version !== PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION) return null;
if (!isFrameProtocolString(data.pluginId, PLUGIN_SETTINGS_FRAME_MAX_PLUGIN_ID_LENGTH)) return null;
if (expectedPluginId !== undefined && data.pluginId !== expectedPluginId) return null;
if (!isFrameProtocolString(data.channelId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH)) return null;
if (data.type === "plugin.settings.ready") {
return hasOnlyFrameMessageKeys(data, ["type", "version", "pluginId", "channelId", "requestId"]) &&
isOptionalFrameProtocolString(data.requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH)
? (data as PluginSettingsReadyMessage)
: null;
}
if (data.type === "plugin.settings.get") {
return hasOnlyFrameMessageKeys(data, ["type", "version", "pluginId", "channelId", "requestId", "key"]) &&
isFrameProtocolString(data.requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH) &&
isSafePluginSettingKey(data.key)
? (data as PluginSettingsGetMessage)
: null;
}
if (data.type === "plugin.settings.set") {
return hasOnlyFrameMessageKeys(data, ["type", "version", "pluginId", "channelId", "requestId", "key", "value"]) &&
isFrameProtocolString(data.requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH) &&
isSafePluginSettingKey(data.key) &&
isPluginSettingValue(data.value)
? (data as PluginSettingsSetMessage)
: null;
}
return null;
}
function extractPluginSettingsFrameRequestId(data: unknown): string | null {
if (!isRecord(data)) return null;
const requestId = data.requestId;
if (typeof requestId !== "string") return null;
return isFrameProtocolString(requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH) ? requestId : null;
}
function isPluginSettingsFrameProtocolEnvelope(data: unknown, expectedPluginId: string): boolean {
if (!isRecord(data)) return false;
return typeof data.type === "string" && data.type.startsWith("plugin.settings.") && data.pluginId === expectedPluginId;
}
function isOptionalFrameProtocolString(value: unknown, maxLength: number) {
return value === undefined || isFrameProtocolString(value, maxLength);
}
function isFrameProtocolString(value: unknown, maxLength: number) {
return typeof value === "string" && value.length > 0 && value.length <= maxLength && PLUGIN_SETTINGS_FRAME_PROTOCOL_STRING_PATTERN.test(value);
}
function hasOnlyFrameMessageKeys(value: Record<string, unknown>, allowedKeys: readonly string[]): boolean {
const allowed = new Set(allowedKeys);
return Object.keys(value).every((key) => allowed.has(key));
}
function postFrameResult(
frameWindow: Window,
pluginId: string,
channelId: string,
result:
| { readonly requestId: string; readonly ok: true; readonly key?: string; readonly value?: JsonLikeValue }
| { readonly requestId: string; readonly ok: false; readonly error: PluginSettingsFrameResultError; readonly message: string }
) {
// `srcDoc` without `allow-same-origin` has an opaque origin, so replies must
// target "*"; inbound validation above binds messages to that exact frame.
frameWindow.postMessage(
{
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
...result,
} satisfies PluginSettingsFrameResultMessage,
"*"
);
}
function resolvePluginSettingsFrameErrorMessages(errorMessages?: Partial<PluginSettingsFrameErrorMessages>): PluginSettingsFrameErrorMessages {
return { ...PLUGIN_SETTINGS_FRAME_DEFAULT_ERROR_MESSAGES, ...errorMessages };
}
function buildFrameErrorResult(requestId: string, error: PluginSettingsFrameResultError, errorMessages: PluginSettingsFrameErrorMessages, message?: string) {
return {
requestId,
ok: false,
error,
message: message ?? errorMessages[error],
} as const;
}
function getPluginSettingsFrameErrorMessage(error: unknown) {
return error instanceof Error && error.message.trim().length > 0 ? error.message : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function escapeAttribute(value: string) {
return value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
function hasHtmlAttribute(attributes: string, attributeName: string) {
return new RegExp(`(?:^|\\s)${attributeName}\\s*=`, "iu").test(attributes);
}
function assertSafePluginSettingsFrameScriptNonce(scriptNonce: string): void {
if (!/^[A-Za-z0-9+/_=-]{1,128}$/u.test(scriptNonce)) {
throw new Error("Plugin settings frame script nonce contains unsafe CSP characters.");
}
}
function createPluginSettingsFrameChannelId() {
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues?.(bytes);
if (bytes.some((byte) => byte !== 0)) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function createPluginSettingsFrameScriptNonce() {
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues?.(bytes);
if (bytes.some((byte) => byte !== 0)) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
return createPluginSettingsFrameChannelId();
}
@@ -0,0 +1,544 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const pluginFindUniqueMock = vi.fn();
const grantUpsertMock = vi.fn();
const grantFindManyMock = vi.fn();
const grantDeleteManyMock = vi.fn();
const pluginFindFirstMock = vi.fn();
const pluginUpdateMock = vi.fn();
const transactionMock = vi.fn();
const auditMock = vi.fn();
const auditOutboxMock = vi.fn();
vi.mock("~/utils/db.server", () => ({
db: {
plugin: {
findUnique: (...args: unknown[]) => pluginFindUniqueMock(...args),
findFirst: (...args: unknown[]) => pluginFindFirstMock(...args),
update: (...args: unknown[]) => pluginUpdateMock(...args),
},
$transaction: (...args: unknown[]) => transactionMock(...args),
tenantPluginCapabilityGrant: {
upsert: (...args: unknown[]) => grantUpsertMock(...args),
findMany: (...args: unknown[]) => grantFindManyMock(...args),
deleteMany: (...args: unknown[]) => grantDeleteManyMock(...args),
},
},
}));
vi.mock("@toolctrl/db/audit-events.server", () => ({
createAuditEvent: (...args: unknown[]) => auditMock(...args),
publishAuditEventViaOutbox: (...args: unknown[]) => auditOutboxMock(...args),
}));
vi.mock("~/utils/logger.server", () => ({
createScopedLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
const actor = { userId: "u-1", email: "admin@example.com", ip: null };
const originalNodeEnv = process.env.NODE_ENV;
const manifest = {
schemaVersion: 1,
id: "com.acme.outbound",
version: "1.0.0",
name: "Outbound",
entry: "plugin.wasm",
wasmSha256: "0".repeat(64),
signature: "0".repeat(128),
signedBy: "0".repeat(64),
allowedHosts: [],
hooks: [{ name: "tenant.created", export: "handleTenantCreated" }],
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }, { name: "log" }],
};
beforeEach(() => {
pluginFindUniqueMock.mockReset();
grantUpsertMock.mockReset();
grantFindManyMock.mockReset();
grantDeleteManyMock.mockReset();
pluginFindFirstMock.mockReset();
pluginUpdateMock.mockReset();
transactionMock.mockReset();
auditMock.mockReset();
auditOutboxMock.mockReset();
transactionMock.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) =>
callback({
plugin: {
findFirst: (...args: unknown[]) => pluginFindFirstMock(...args),
update: (...args: unknown[]) => pluginUpdateMock(...args),
},
tenantPluginCapabilityGrant: {
findMany: (...args: unknown[]) => grantFindManyMock(...args),
upsert: (...args: unknown[]) => grantUpsertMock(...args),
deleteMany: (...args: unknown[]) => grantDeleteManyMock(...args),
},
})
);
auditOutboxMock.mockResolvedValue(undefined);
});
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
});
describe("plugin lifecycle capability grants", () => {
test("accepts grants whose scope matches or narrows the manifest", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
grantUpsertMock.mockImplementation(async ({ create }) => ({
id: "grant-1",
tenantId: create.tenantId,
pluginId: create.pluginId,
capabilityName: create.capabilityName,
scopeJson: create.scopeJson,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: create.grantedByUserId,
}));
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
actor,
})
).resolves.toHaveLength(1);
expect(transactionMock).toHaveBeenCalledOnce();
expect(auditOutboxMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "plugin.capabilities.granted",
targetType: "plugin",
targetId: "row-1",
sourceModule: "plugin-lifecycle",
outcome: "success",
metadata: expect.objectContaining({
pluginId: manifest.id,
capabilityReviewDigest: null,
grantedCapabilities: ["http.outbound"],
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
manifestCapabilities: [
expect.objectContaining({
capabilityName: "http.outbound",
declaredScope: { allowedHosts: ["api.example.com"] },
kind: "capability",
}),
],
}),
})
);
});
test("rejects grants that broaden manifest scope", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }],
actor,
})
).rejects.toMatchObject({ code: "capability_scope_invalid" });
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("rejects unavailable host capability grants in production", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
actor,
})
).rejects.toMatchObject({ code: "capability_unavailable" });
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("rejects malformed or invented scope for unscoped capabilities", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "log", scopeJson: { tablePrefix: "tenant_" } }],
actor,
})
).rejects.toMatchObject({ code: "capability_scope_invalid" });
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "log", scopeJson: "all" }],
actor,
})
).rejects.toMatchObject({ code: "capability_scope_invalid" });
});
test("does not treat stale grants as sufficient after manifest scope changes", async () => {
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
pluginId: manifest.id,
manifest: {
...manifest,
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com", "api2.example.com"] } }],
},
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "http.outbound",
scopeJson: { allowedHosts: ["api.example.com", "internal.example"] },
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "capability_consent_missing",
});
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("requires manifest hook consent before enabling hook-only plugins", async () => {
const hookOnlyManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
pluginId: manifest.id,
manifest: hookOnlyManifest,
});
grantFindManyMock.mockResolvedValue([]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "capability_consent_missing",
message: expect.stringContaining("hook:workflow.executed"),
});
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("allows hook consent grants declared by the manifest", async () => {
const hookOnlyManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
};
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: hookOnlyManifest });
grantUpsertMock.mockImplementation(async ({ create }) => ({
id: "grant-1",
tenantId: create.tenantId,
pluginId: create.pluginId,
capabilityName: create.capabilityName,
scopeJson: create.scopeJson,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: create.grantedByUserId,
}));
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "hook:workflow.executed", scopeJson: null }],
actor,
})
).resolves.toHaveLength(1);
expect(grantUpsertMock).toHaveBeenCalledWith(expect.objectContaining({ create: expect.objectContaining({ capabilityName: "hook:workflow.executed" }) }));
});
test("rejects production hook consent grants for registered hooks outside the production allowlist", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
const { registerHook } = await import("@toolctrl/plugin-runtime");
const { z } = await import("zod");
registerHook({
name: "billing.refunded",
outboxEventType: "plugin.hook.billing.refunded",
payloadSchema: z.object({ invoiceId: z.string() }),
});
const hookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "billing.refunded", export: "onBillingRefunded" }],
};
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: hookManifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "hook:billing.refunded", scopeJson: null }],
actor,
})
).rejects.toMatchObject({ code: "hook_unavailable" });
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("rejects hook consent grants for hooks not registered by the host", async () => {
const unregisteredHookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.finished", export: "onWorkflowFinished" }],
};
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: unregisteredHookManifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "hook:workflow.finished", scopeJson: null }],
actor,
})
).rejects.toMatchObject({
code: "manifest_invalid",
message: expect.stringContaining("workflow.finished"),
});
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("enables hook-only plugins after hook consent is granted", async () => {
const hookOnlyManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: false,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
manifest: hookOnlyManifest,
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "hook:workflow.executed",
scopeJson: null,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
pluginUpdateMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: true,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
manifest: hookOnlyManifest,
});
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).resolves.toMatchObject({ enabled: true });
expect(pluginUpdateMock).toHaveBeenCalledWith({ where: { id: "row-1" }, data: { enabled: true } });
});
test("rejects enable for installed plugins with unregistered manifest hooks even when consent exists", async () => {
const unregisteredHookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.finished", export: "onWorkflowFinished" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: false,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
manifest: unregisteredHookManifest,
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "hook:workflow.finished",
scopeJson: null,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "manifest_invalid",
message: expect.stringContaining("workflow.finished"),
});
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("rejects enable for registered hooks outside the production allowlist", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
const { registerHook } = await import("@toolctrl/plugin-runtime");
const { z } = await import("zod");
registerHook({
name: "billing.failed",
outboxEventType: "plugin.hook.billing.failed",
payloadSchema: z.object({ invoiceId: z.string() }),
});
const hookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "billing.failed", export: "onBillingFailed" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: false,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
manifest: hookManifest,
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "hook:billing.failed",
scopeJson: null,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "hook_unavailable",
});
expect(grantFindManyMock).not.toHaveBeenCalled();
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("rejects unavailable host capabilities before enabling in production", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
pluginId: manifest.id,
manifest,
});
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "capability_unavailable",
});
expect(grantFindManyMock).not.toHaveBeenCalled();
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("revokes selected grants without disabling an already disabled plugin", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: false, manifest });
grantDeleteManyMock.mockResolvedValue({ count: 1 });
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
await expect(
revokePluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
capabilityNames: ["log"],
actor,
})
).resolves.toEqual({ revokedCapabilities: ["log"], autoDisabled: false });
expect(pluginUpdateMock).not.toHaveBeenCalled();
expect(grantDeleteManyMock).toHaveBeenCalledWith({
where: {
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: { in: ["log"] },
},
});
expect(transactionMock).toHaveBeenCalledOnce();
expect(auditOutboxMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "plugin.capabilities.revoked",
metadata: { pluginId: manifest.id, revokedCapabilities: ["log"], autoDisabled: false },
})
);
});
test("auto-disables enabled plugins before revoking grants", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: true, manifest });
pluginUpdateMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: false, manifest });
grantDeleteManyMock.mockResolvedValue({ count: 1 });
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
await expect(
revokePluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
capabilityNames: ["http.outbound"],
actor,
})
).resolves.toEqual({ revokedCapabilities: ["http.outbound"], autoDisabled: true });
expect(pluginUpdateMock).toHaveBeenCalledWith({
where: { id: "row-1" },
data: { enabled: false },
});
expect(grantDeleteManyMock).toHaveBeenCalledOnce();
expect(transactionMock).toHaveBeenCalledOnce();
expect(auditOutboxMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "plugin.capabilities.revoked",
metadata: { pluginId: manifest.id, revokedCapabilities: ["http.outbound"], autoDisabled: true },
})
);
});
test("rejects revoke requests for capabilities not declared in the manifest", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: true, manifest });
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
await expect(
revokePluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
capabilityNames: ["db.write"],
actor,
})
).rejects.toMatchObject({ code: "capability_not_in_manifest" });
expect(pluginUpdateMock).not.toHaveBeenCalled();
expect(grantDeleteManyMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,762 @@
/**
* 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,
};
}
@@ -0,0 +1,517 @@
import { promises as nodeFs } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { PluginManifest } from "@toolctrl/plugin-runtime";
const getPluginForTenantMock = vi.fn();
const publishAuditEventViaOutboxMock = vi.fn();
const dbMock = {
$transaction: vi.fn(),
pluginUserConfig: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
};
vi.mock("./PluginLifecycleService", () => ({
getPluginForTenant: (...args: unknown[]) => getPluginForTenantMock(...args),
}));
vi.mock("@toolctrl/db/audit-events.server", () => ({
publishAuditEventViaOutbox: (...args: unknown[]) => publishAuditEventViaOutboxMock(...args),
}));
vi.mock("~/utils/db.server", () => ({
db: dbMock,
}));
const baseManifest = {
schemaVersion: 1,
id: "com.acme.settings",
version: "1.0.0",
name: "Settings",
entry: "plugin.wasm",
wasmSha256: "0".repeat(64),
signature: "0".repeat(128),
signedBy: "0".repeat(64),
allowedHosts: [],
capabilities: [],
hooks: [{ name: "entity.created", export: "handleEntityCreated" }],
} satisfies PluginManifest;
const settingsHtml = "<main>Settings</main>";
const settingsHtmlSha256 = createHash("sha256").update(settingsHtml).digest("hex");
const settingsHtmlIntegrity = `sha256-${createHash("sha256").update(settingsHtml).digest("base64")}`;
function pluginFixture(manifest: PluginManifest = { ...baseManifest, ui: { settings: { entry: "ui/settings.html", sha256: settingsHtmlSha256 } } }) {
return {
id: "plugin-row-1",
pluginId: "com.acme.settings",
version: "1.0.0",
enabled: true,
manifest,
installedAt: new Date("2026-05-01T00:00:00.000Z"),
updatedAt: new Date("2026-05-01T00:00:00.000Z"),
};
}
beforeEach(() => {
getPluginForTenantMock.mockReset();
publishAuditEventViaOutboxMock.mockReset();
publishAuditEventViaOutboxMock.mockResolvedValue(undefined);
dbMock.$transaction.mockReset();
dbMock.$transaction.mockImplementation((callback: (tx: typeof dbMock) => unknown) => callback(dbMock));
dbMock.pluginUserConfig.findUnique.mockReset();
dbMock.pluginUserConfig.upsert.mockReset();
delete process.env.PLUGIN_BINARY_BASE_PATH;
});
describe("PluginSettingsService", () => {
test("loads settings HTML through tenant-scoped plugin lookup", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
await mkdir(bundleRoot, { recursive: true });
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
process.env.PLUGIN_BINARY_BASE_PATH = root;
getPluginForTenantMock.mockResolvedValue(pluginFixture());
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
const result = await loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
expect(result.entry).toBe("ui/settings.html");
expect(result.trustedHtml).toBe(settingsHtml);
});
test("loads settings HTML declared with SRI integrity metadata", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
await mkdir(bundleRoot, { recursive: true });
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
process.env.PLUGIN_BINARY_BASE_PATH = root;
getPluginForTenantMock.mockResolvedValue(
pluginFixture({ ...baseManifest, ui: { settings: { entry: "ui/settings.html", integrity: settingsHtmlIntegrity } } })
);
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
const result = await loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
expect(result.trustedHtml).toBe(settingsHtml);
});
test("rejects manifests without settings UI", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture(baseManifest));
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
code: "settings_ui_unavailable",
});
});
test("rejects settings UI manifests without HTML integrity metadata before touching the filesystem", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture({ ...baseManifest, ui: { settings: { entry: "ui/settings.html" } } as never }));
const openSpy = vi.spyOn(nodeFs, "open");
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
try {
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
code: "settings_ui_integrity_missing",
});
expect(openSpy).not.toHaveBeenCalled();
} finally {
openSpy.mockRestore();
}
});
test("rejects unsafe entries before touching the filesystem", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
code: "settings_ui_entry_unsafe",
});
});
test("rejects settings HTML whose bytes differ from the manifest sha256", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
await mkdir(bundleRoot, { recursive: true });
await writeFile(path.join(bundleRoot, "settings.html"), "<main>Tampered</main>");
process.env.PLUGIN_BINARY_BASE_PATH = root;
getPluginForTenantMock.mockResolvedValue(pluginFixture());
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
code: "settings_ui_integrity_mismatch",
});
});
test("rejects symlink traversal out of the plugin bundle", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
const outside = path.join(root, "outside.html");
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
await mkdir(bundleRoot, { recursive: true });
await writeFile(outside, "<main>Outside</main>");
await symlink(outside, path.join(bundleRoot, "settings.html"));
process.env.PLUGIN_BINARY_BASE_PATH = root;
getPluginForTenantMock.mockResolvedValue(pluginFixture());
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
code: "settings_ui_entry_unsafe",
});
});
test("rejects oversized settings HTML after opening the file handle and before reading it into the route payload", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
const settingsPath = path.join(bundleRoot, "settings.html");
await mkdir(bundleRoot, { recursive: true });
await writeFile(settingsPath, settingsHtml);
process.env.PLUGIN_BINARY_BASE_PATH = root;
getPluginForTenantMock.mockResolvedValue(pluginFixture());
const originalOpen = nodeFs.open.bind(nodeFs);
const openSpy = vi.spyOn(nodeFs, "open").mockImplementation(async (...args: Parameters<typeof nodeFs.open>) => {
const fileHandle = (await originalOpen(...args)) as FileHandle;
const originalStat = fileHandle.stat.bind(fileHandle);
vi.spyOn(fileHandle, "stat").mockImplementation(async (...statArgs: Parameters<FileHandle["stat"]>) => {
await writeFile(settingsPath, `<main>${"x".repeat(257 * 1024)}</main>`);
return originalStat(...statArgs);
});
return fileHandle;
});
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
try {
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
code: "settings_ui_file_too_large",
});
expect(openSpy).toHaveBeenCalledWith(expect.stringContaining("settings.html"), "r");
} finally {
openSpy.mockRestore();
}
});
test("loads per-user settings through tenant-scoped plugin lookup", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture());
dbMock.pluginUserConfig.findUnique.mockResolvedValue({
configJson: {
theme: "dark",
count: 2,
apiKey: "must-not-reach-frame",
password: "must-not-reach-frame",
"../escape": "unsafe",
__proto__: "polluted",
constructor: "unsafe",
prototype: "unsafe",
oversized: "x".repeat(33 * 1024),
tooManyItems: Array.from({ length: 513 }, (_, index) => index),
tooManyKeys: Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`k${index}`, index])),
nestedReserved: JSON.parse('{"safe":true,"__proto__":{"polluted":true}}'),
nestedSecret: { appearance: { apiKey: "must-not-reach-frame" } },
ignored: () => "nope",
},
});
const { loadPluginUserSettings } = await import("./PluginSettingsService");
const result = await loadPluginUserSettings({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" });
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
expect(dbMock.pluginUserConfig.findUnique).toHaveBeenCalledWith({
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
select: { configJson: true },
});
expect(result).toEqual({ theme: "dark", count: 2 });
expect(JSON.stringify(result)).not.toContain("must-not-reach-frame");
expect(result).not.toHaveProperty("apiKey");
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("../escape");
expect(result).not.toHaveProperty("__proto__");
expect(result).not.toHaveProperty("constructor");
expect(result).not.toHaveProperty("prototype");
expect(result).not.toHaveProperty("oversized");
expect(result).not.toHaveProperty("tooManyItems");
expect(result).not.toHaveProperty("tooManyKeys");
expect(result).not.toHaveProperty("nestedReserved");
expect(result).not.toHaveProperty("nestedSecret");
});
test("loads settings page data with a single tenant plugin lookup", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
await mkdir(bundleRoot, { recursive: true });
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
process.env.PLUGIN_BINARY_BASE_PATH = root;
getPluginForTenantMock.mockResolvedValue(pluginFixture());
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "dark" } });
const { loadPluginSettingsPageData } = await import("./PluginSettingsService");
const result = await loadPluginSettingsPageData({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" });
expect(getPluginForTenantMock).toHaveBeenCalledOnce();
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
expect(dbMock.pluginUserConfig.findUnique).toHaveBeenCalledWith({
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
select: { configJson: true },
});
expect(result).toMatchObject({
entry: "ui/settings.html",
trustedHtml: settingsHtml,
settings: { theme: "dark" },
plugin: { id: "plugin-row-1", pluginId: "com.acme.settings", version: "1.0.0" },
});
});
test("rejects per-user settings reads for disabled plugins before reading config", async () => {
getPluginForTenantMock.mockResolvedValue({ ...pluginFixture(), enabled: false });
const { loadPluginUserSettings } = await import("./PluginSettingsService");
await expect(loadPluginUserSettings({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" })).rejects.toMatchObject({
code: "plugin_disabled",
});
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
});
test("rejects per-user settings reads when no safe settings UI is declared before reading config", async () => {
const { loadPluginUserSettings } = await import("./PluginSettingsService");
const args = { tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" };
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
await expect(loadPluginUserSettings(args)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
await expect(loadPluginUserSettings(args)).rejects.toMatchObject({ code: "settings_ui_entry_unsafe" });
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
});
test("upserts a single per-user setting without storing secret-like keys", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture());
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "dark" } });
dbMock.pluginUserConfig.upsert.mockResolvedValue({});
const { setPluginUserSetting } = await import("./PluginSettingsService");
const result = await setPluginUserSetting({
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
userId: "user-1",
key: "layout.mode",
value: "compact",
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
});
expect(dbMock.pluginUserConfig.upsert).toHaveBeenCalledWith({
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
create: { pluginId: "plugin-row-1", userId: "user-1", configJson: { theme: "dark", "layout.mode": "compact" } },
update: { configJson: { theme: "dark", "layout.mode": "compact" } },
});
expect(dbMock.$transaction).toHaveBeenCalledOnce();
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(
dbMock,
expect.objectContaining({
tenantId: "tenant-1",
actorId: "user-1",
action: "plugin.configuration.updated",
targetId: "plugin-row-1",
metadata: { pluginId: "com.acme.settings", key: "layout.mode" },
})
);
expect(JSON.stringify(publishAuditEventViaOutboxMock.mock.calls[0]?.[1]?.metadata)).not.toContain("compact");
expect(result.settings).toEqual({ theme: "dark", "layout.mode": "compact" });
});
test("merges per-user settings from the write transaction snapshot", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture());
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "stale" } });
const tx = {
pluginUserConfig: {
findUnique: vi.fn().mockResolvedValue({ configJson: { theme: "dark", density: "comfortable" } }),
upsert: vi.fn().mockResolvedValue({}),
},
};
dbMock.$transaction.mockImplementationOnce((callback: (tx: typeof tx) => unknown) => callback(tx));
const { setPluginUserSetting } = await import("./PluginSettingsService");
const result = await setPluginUserSetting({
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
userId: "user-1",
key: "layout.mode",
value: "compact",
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
});
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
expect(tx.pluginUserConfig.findUnique).toHaveBeenCalledWith({
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
select: { configJson: true },
});
expect(tx.pluginUserConfig.upsert).toHaveBeenCalledWith({
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
create: { pluginId: "plugin-row-1", userId: "user-1", configJson: { theme: "dark", density: "comfortable", "layout.mode": "compact" } },
update: { configJson: { theme: "dark", density: "comfortable", "layout.mode": "compact" } },
});
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(tx, expect.objectContaining({ action: "plugin.configuration.updated" }));
expect(result.settings).toEqual({ theme: "dark", density: "comfortable", "layout.mode": "compact" });
});
test("rolls back per-user setting writes when audit enqueue fails", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture());
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: {} });
dbMock.pluginUserConfig.upsert.mockResolvedValue({});
publishAuditEventViaOutboxMock.mockRejectedValueOnce(new Error("outbox unavailable"));
const { setPluginUserSetting } = await import("./PluginSettingsService");
await expect(
setPluginUserSetting({
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
userId: "user-1",
key: "theme",
value: "dark",
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
})
).rejects.toThrow("outbox unavailable");
expect(dbMock.$transaction).toHaveBeenCalledOnce();
expect(dbMock.pluginUserConfig.upsert).toHaveBeenCalledOnce();
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledOnce();
});
test("reports settings iframe violations as redacted tenant-scoped audit events", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture());
const { reportPluginSettingsFrameViolation } = await import("./PluginSettingsService");
await reportPluginSettingsFrameViolation({
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
reason: "wrong_origin",
actor: { userId: "user-1", email: "user@example.com", ip: "203.0.113.7" },
});
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(dbMock, {
tenantId: "tenant-1",
actorId: "user-1",
actorType: "user",
actorEmail: "user@example.com",
actorIp: "203.0.113.7",
action: "plugin.settings_frame.violation",
targetType: "plugin",
targetId: "plugin-row-1",
sourceModule: "plugin-settings",
outcome: "blocked",
dataSensitivity: "internal",
metadata: { pluginId: "com.acme.settings", pluginRowId: "plugin-row-1", reason: "wrong_origin", reportedByClient: true },
});
expect(JSON.stringify(publishAuditEventViaOutboxMock.mock.calls[0]?.[1])).not.toContain("payload");
});
test("rejects settings iframe violation reports for disabled plugins or missing settings UI", async () => {
const { reportPluginSettingsFrameViolation } = await import("./PluginSettingsService");
const base = {
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
reason: "invalid_payload" as const,
actor: { userId: "user-1", email: "user@example.com", ip: null },
};
getPluginForTenantMock.mockResolvedValueOnce({ ...pluginFixture(), enabled: false });
await expect(reportPluginSettingsFrameViolation(base)).rejects.toMatchObject({ code: "plugin_disabled" });
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
await expect(reportPluginSettingsFrameViolation(base)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
});
test("rejects direct per-user setting writes for disabled plugins", async () => {
getPluginForTenantMock.mockResolvedValue({ ...pluginFixture(), enabled: false });
const { setPluginUserSetting } = await import("./PluginSettingsService");
await expect(
setPluginUserSetting({
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
userId: "user-1",
key: "theme",
value: "dark",
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
})
).rejects.toMatchObject({ code: "plugin_disabled" });
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
});
test("rejects direct per-user setting writes when no safe settings UI is declared", async () => {
const { setPluginUserSetting } = await import("./PluginSettingsService");
const base = {
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
userId: "user-1",
key: "theme",
value: "dark",
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
};
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
await expect(setPluginUserSetting(base)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
await expect(setPluginUserSetting(base)).rejects.toMatchObject({ code: "settings_ui_entry_unsafe" });
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
});
test("rejects unsafe, secret-like, invalid, and oversized setting writes", async () => {
getPluginForTenantMock.mockResolvedValue(pluginFixture());
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: {} });
const { setPluginUserSetting } = await import("./PluginSettingsService");
const base = {
tenantId: "tenant-1",
pluginRowId: "plugin-row-1",
userId: "user-1",
actor: { userId: "user-1", email: null, ip: null },
};
await expect(setPluginUserSetting({ ...base, key: "../theme", value: "dark" })).rejects.toMatchObject({ code: "settings_key_invalid" });
await expect(setPluginUserSetting({ ...base, key: "k".repeat(129), value: "dark" })).rejects.toMatchObject({ code: "settings_key_invalid" });
await expect(setPluginUserSetting({ ...base, key: "__proto__", value: "polluted" })).rejects.toMatchObject({ code: "settings_key_invalid" });
await expect(setPluginUserSetting({ ...base, key: "constructor", value: "unsafe" })).rejects.toMatchObject({ code: "settings_key_invalid" });
await expect(setPluginUserSetting({ ...base, key: "prototype", value: "unsafe" })).rejects.toMatchObject({ code: "settings_key_invalid" });
await expect(setPluginUserSetting({ ...base, key: "apiKey", value: "abc" })).rejects.toMatchObject({ code: "settings_key_secret_like" });
await expect(setPluginUserSetting({ ...base, key: "theme", value: Number.NaN as never })).rejects.toMatchObject({ code: "settings_value_invalid" });
await expect(
setPluginUserSetting({ ...base, key: "deep", value: { a: { b: { c: { d: { e: { f: { g: { h: { i: true } } } } } } } } } })
).rejects.toMatchObject({
code: "settings_value_invalid",
});
await expect(setPluginUserSetting({ ...base, key: "array", value: Array.from({ length: 513 }, (_, index) => index) })).rejects.toMatchObject({
code: "settings_value_invalid",
});
await expect(
setPluginUserSetting({ ...base, key: "object", value: Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`k${index}`, index])) })
).rejects.toMatchObject({
code: "settings_value_invalid",
});
await expect(
setPluginUserSetting({ ...base, key: "nestedReserved", value: JSON.parse('{"theme":"dark","__proto__":{"polluted":true}}') })
).rejects.toMatchObject({
code: "settings_value_invalid",
});
await expect(setPluginUserSetting({ ...base, key: "appearance", value: { theme: { accessToken: "must-not-persist" } } })).rejects.toMatchObject({
code: "settings_value_invalid",
});
await expect(setPluginUserSetting({ ...base, key: "blob", value: "x".repeat(70 * 1024) })).rejects.toMatchObject({
code: "settings_value_too_large",
});
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,342 @@
import { promises as fs } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import type { PluginManifest } from "@toolctrl/plugin-runtime";
import { publishAuditEventViaOutbox } from "@toolctrl/db/audit-events.server";
import { db } from "~/utils/db.server";
import {
PLUGIN_SETTING_VALUE_MAX_BYTES,
isPluginSettingKeyFormatSafe,
isPluginSettingKeyReserved,
isPluginSettingKeySecretLike,
isPluginSettingValue,
isPluginSettingValueShapeSafe,
isPluginSettingValueWithinSize,
isSafePluginSettingKey,
type PluginSettingValue,
} from "../../utils/pluginSettingsValidation";
import { getPluginForTenant, type PluginLifecycleActor, type PluginListItem } from "./PluginLifecycleService";
export type { PluginSettingValue } from "../../utils/pluginSettingsValidation";
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
const RELATIVE_HTML_ENTRY_PATTERN = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?![a-z][a-z0-9+.-]*:)[a-z0-9][a-z0-9._/-]*\.html$/iu;
const MAX_PLUGIN_USER_CONFIG_BYTES = 64 * 1024;
const MAX_PLUGIN_SETTINGS_HTML_BYTES = 256 * 1024;
const MAX_PLUGIN_SETTING_VALUE_BYTES = PLUGIN_SETTING_VALUE_MAX_BYTES;
export type PluginSettingsServiceErrorCode =
| "plugin_not_found"
| "plugin_disabled"
| "settings_ui_unavailable"
| "settings_ui_entry_unsafe"
| "settings_ui_file_not_found"
| "settings_ui_file_too_large"
| "settings_ui_integrity_missing"
| "settings_ui_integrity_mismatch"
| "settings_key_invalid"
| "settings_key_secret_like"
| "settings_value_invalid"
| "settings_value_too_large";
export class PluginSettingsServiceError extends Error {
public override readonly name = "PluginSettingsServiceError";
constructor(
public readonly code: PluginSettingsServiceErrorCode,
message: string
) {
super(message);
}
}
export type PluginSettingsHtml = {
plugin: PluginListItem;
entry: string;
trustedHtml: string;
};
export type PluginUserSettings = Readonly<Record<string, PluginSettingValue>>;
export type PluginSettingsPageData = PluginSettingsHtml & {
settings: PluginUserSettings;
};
export type PluginSettingsFrameViolationReason = "wrong_origin" | "wrong_source" | "invalid_payload" | "unexpected_navigation";
export async function loadPluginSettingsHtml(args: { tenantId: string; pluginRowId: string }): Promise<PluginSettingsHtml> {
const plugin = await getPluginForTenant({ tenantId: args.tenantId, pluginRowId: args.pluginRowId });
if (!plugin) {
throw new PluginSettingsServiceError("plugin_not_found", `Plugin row ${args.pluginRowId} was not found for this tenant.`);
}
return loadPluginSettingsHtmlForPlugin(plugin);
}
export async function loadPluginSettingsPageData(args: { tenantId: string; pluginRowId: string; userId: string }): Promise<PluginSettingsPageData> {
const plugin = await resolveTenantPlugin(args);
const [settingsHtml, settings] = await Promise.all([
loadPluginSettingsHtmlForPlugin(plugin),
loadPluginUserSettingsForPlugin({ plugin, userId: args.userId }),
]);
return { ...settingsHtml, settings };
}
async function loadPluginSettingsHtmlForPlugin(plugin: PluginListItem): Promise<PluginSettingsHtml> {
if (!plugin.enabled) {
throw new PluginSettingsServiceError("settings_ui_unavailable", `Plugin ${plugin.pluginId} is disabled.`);
}
const entry = assertPluginDeclaresSafeSettingsUi(plugin);
const bundleRoot = path.join(resolvePluginBundleBasePath(), plugin.pluginId, plugin.version);
let rootReal: string;
let fileReal: string;
try {
rootReal = await fs.realpath(bundleRoot);
fileReal = await fs.realpath(path.resolve(rootReal, entry));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new PluginSettingsServiceError("settings_ui_file_not_found", `Plugin settings UI file ${entry} was not found.`);
}
throw error;
}
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
if (!fileReal.startsWith(rootPrefix)) {
throw new PluginSettingsServiceError("settings_ui_entry_unsafe", `Plugin settings UI entry ${entry} escapes the plugin bundle.`);
}
const fileHandle = await fs.open(fileReal, "r");
try {
const fileStats = await fileHandle.stat();
if (fileStats.size > MAX_PLUGIN_SETTINGS_HTML_BYTES) {
throw new PluginSettingsServiceError("settings_ui_file_too_large", `Plugin settings UI file ${entry} exceeds ${MAX_PLUGIN_SETTINGS_HTML_BYTES} bytes.`);
}
const htmlBytes = await fileHandle.readFile();
if (htmlBytes.byteLength > MAX_PLUGIN_SETTINGS_HTML_BYTES) {
throw new PluginSettingsServiceError("settings_ui_file_too_large", `Plugin settings UI file ${entry} exceeds ${MAX_PLUGIN_SETTINGS_HTML_BYTES} bytes.`);
}
assertSettingsHtmlIntegrity({ plugin, entry, htmlBytes });
return {
plugin,
entry,
trustedHtml: htmlBytes.toString("utf8"),
};
} finally {
await fileHandle.close();
}
}
export async function loadPluginUserSettings(args: { tenantId: string; pluginRowId: string; userId: string }): Promise<PluginUserSettings> {
const plugin = await resolveTenantPlugin(args);
return loadPluginUserSettingsForPlugin({ plugin, userId: args.userId });
}
async function loadPluginUserSettingsForPlugin({ plugin, userId }: { plugin: PluginListItem; userId: string }): Promise<PluginUserSettings> {
if (!plugin.enabled) {
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
}
assertPluginDeclaresSafeSettingsUi(plugin);
const config = await db.pluginUserConfig.findUnique({
where: { pluginId_userId: { pluginId: plugin.id, userId } },
select: { configJson: true },
});
return normalizeSettingsConfig(config?.configJson);
}
export async function setPluginUserSetting(args: {
tenantId: string;
pluginRowId: string;
userId: string;
key: string;
value: PluginSettingValue;
actor: PluginLifecycleActor;
}): Promise<{ plugin: PluginListItem; settings: PluginUserSettings; key: string; value: PluginSettingValue }> {
const plugin = await resolveTenantPlugin(args);
if (!plugin.enabled) {
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
}
assertPluginDeclaresSafeSettingsUi(plugin);
assertSafePluginSettingKeyForWrite(args.key);
assertPluginSettingValue(args.value);
const settings = await db.$transaction(async (tx) => {
const current = await tx.pluginUserConfig.findUnique({
where: { pluginId_userId: { pluginId: plugin.id, userId: args.userId } },
select: { configJson: true },
});
const nextSettings = {
...normalizeSettingsConfig(current?.configJson),
[args.key]: args.value,
} satisfies PluginUserSettings;
assertPluginSettingsConfigSize(nextSettings);
await tx.pluginUserConfig.upsert({
where: { pluginId_userId: { pluginId: plugin.id, userId: args.userId } },
create: {
pluginId: plugin.id,
userId: args.userId,
configJson: nextSettings as never,
},
update: {
configJson: nextSettings as never,
},
});
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.configuration.updated",
targetType: "plugin",
targetId: plugin.id,
sourceModule: "plugin-settings",
outcome: "success",
dataSensitivity: "internal",
metadata: { pluginId: plugin.pluginId, key: args.key },
});
return nextSettings;
});
return { plugin, settings, key: args.key, value: args.value };
}
export async function reportPluginSettingsFrameViolation(args: {
tenantId: string;
pluginRowId: string;
reason: PluginSettingsFrameViolationReason;
actor: PluginLifecycleActor;
}): Promise<void> {
const plugin = await resolveTenantPlugin(args);
if (!plugin.enabled) {
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
}
assertPluginDeclaresSafeSettingsUi(plugin);
await publishAuditEventViaOutbox(db, {
tenantId: args.tenantId,
actorId: args.actor.userId,
actorType: args.actor.userId ? "user" : "system",
actorEmail: args.actor.email,
actorIp: args.actor.ip,
action: "plugin.settings_frame.violation",
targetType: "plugin",
targetId: plugin.id,
sourceModule: "plugin-settings",
outcome: "blocked",
dataSensitivity: "internal",
metadata: { pluginId: plugin.pluginId, pluginRowId: plugin.id, reason: args.reason, reportedByClient: true },
});
}
export function getPluginSettingsEntry(manifest: PluginManifest): string | null {
const entry = manifest.ui?.settings?.entry;
return typeof entry === "string" && entry.length > 0 ? entry : null;
}
export function assertSafePluginSettingsEntry(entry: string): void {
if (!RELATIVE_HTML_ENTRY_PATTERN.test(entry)) {
throw new PluginSettingsServiceError("settings_ui_entry_unsafe", `Plugin settings UI entry ${entry} is not a safe relative HTML path.`);
}
}
function assertPluginDeclaresSafeSettingsUi(plugin: PluginListItem): string {
const entry = getPluginSettingsEntry(plugin.manifest);
if (!entry) {
throw new PluginSettingsServiceError("settings_ui_unavailable", `Plugin ${plugin.pluginId} does not declare a settings UI.`);
}
assertSafePluginSettingsEntry(entry);
if (!getPluginSettingsExpectedSha256(plugin.manifest)) {
throw new PluginSettingsServiceError(
"settings_ui_integrity_missing",
`Plugin ${plugin.pluginId} settings UI ${entry} does not declare sha256 or integrity.`
);
}
return entry;
}
function getPluginSettingsExpectedSha256(manifest: PluginManifest): string | null {
const settings = manifest.ui?.settings;
if (!settings) return null;
if (typeof settings.sha256 === "string" && settings.sha256.length > 0) {
return settings.sha256;
}
if (typeof settings.integrity === "string" && settings.integrity.startsWith("sha256-")) {
return Buffer.from(settings.integrity.slice("sha256-".length), "base64").toString("hex");
}
return null;
}
function assertSettingsHtmlIntegrity({ plugin, entry, htmlBytes }: { plugin: PluginListItem; entry: string; htmlBytes: Buffer }): void {
const expectedSha256 = getPluginSettingsExpectedSha256(plugin.manifest);
if (!expectedSha256) {
throw new PluginSettingsServiceError(
"settings_ui_integrity_missing",
`Plugin ${plugin.pluginId} settings UI ${entry} does not declare sha256 or integrity.`
);
}
const actualSha256 = createHash("sha256").update(htmlBytes).digest("hex");
if (actualSha256 !== expectedSha256) {
throw new PluginSettingsServiceError("settings_ui_integrity_mismatch", `Plugin settings UI file ${entry} does not match the manifest sha256.`);
}
}
function resolvePluginBundleBasePath(): string {
return process.env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
}
async function resolveTenantPlugin(args: { tenantId: string; pluginRowId: string }): Promise<PluginListItem> {
const plugin = await getPluginForTenant({ tenantId: args.tenantId, pluginRowId: args.pluginRowId });
if (!plugin) {
throw new PluginSettingsServiceError("plugin_not_found", `Plugin row ${args.pluginRowId} was not found for this tenant.`);
}
return plugin;
}
function normalizeSettingsConfig(config: unknown): PluginUserSettings {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return {};
}
const settings: Record<string, PluginSettingValue> = {};
for (const [key, value] of Object.entries(config)) {
if (isSafePluginSettingKey(key) && isPluginSettingValue(value) && isPluginSettingValueWithinSize(value)) {
settings[key] = value;
}
}
return settings;
}
function assertPluginSettingKeyNotSecretLike(key: string): void {
if (isPluginSettingKeySecretLike(key)) {
throw new PluginSettingsServiceError("settings_key_secret_like", `Plugin setting key ${key} looks like a secret.`);
}
}
function assertSafePluginSettingKeyForWrite(key: string): void {
if (!isPluginSettingKeyFormatSafe(key)) {
throw new PluginSettingsServiceError("settings_key_invalid", `Plugin setting key ${key} is not a safe key.`);
}
if (isPluginSettingKeyReserved(key)) {
throw new PluginSettingsServiceError("settings_key_invalid", `Plugin setting key ${key} is reserved.`);
}
assertPluginSettingKeyNotSecretLike(key);
}
function assertPluginSettingValue(value: unknown): asserts value is PluginSettingValue {
if (!isPluginSettingValueShapeSafe(value)) {
throw new PluginSettingsServiceError("settings_value_invalid", "Plugin setting value must be JSON-serializable.");
}
assertPluginSettingValueSize(value);
}
function assertPluginSettingValueSize(value: PluginSettingValue): void {
if (!isPluginSettingValueWithinSize(value)) {
throw new PluginSettingsServiceError("settings_value_too_large", `Plugin setting value exceeds ${MAX_PLUGIN_SETTING_VALUE_BYTES} bytes.`);
}
}
function assertPluginSettingsConfigSize(settings: PluginUserSettings): void {
const bytes = Buffer.byteLength(JSON.stringify(settings), "utf8");
if (bytes > MAX_PLUGIN_USER_CONFIG_BYTES) {
throw new PluginSettingsServiceError("settings_value_too_large", `Plugin settings exceed ${MAX_PLUGIN_USER_CONFIG_BYTES} bytes.`);
}
}
@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type PluginHookPublisherMocks = {
transaction: ReturnType<typeof vi.fn>;
publishOutboxEvent: ReturnType<typeof vi.fn>;
emitPluginHook: ReturnType<typeof vi.fn>;
loggerWarn: ReturnType<typeof vi.fn>;
loggerError: ReturnType<typeof vi.fn>;
};
const { TestPluginHookValidationError } = vi.hoisted(() => {
class TestPluginHookValidationError extends Error {
public override readonly name = "PluginHookValidationError";
public readonly hookName: string;
public readonly issues: unknown[];
constructor(hookName: string, issues: unknown[]) {
super(`Invalid payload for plugin hook '${hookName}'`);
this.hookName = hookName;
this.issues = issues;
}
}
return { TestPluginHookValidationError };
});
function getMocks(): PluginHookPublisherMocks {
const store = globalThis as typeof globalThis & { __pluginHookPublisherMocks__?: PluginHookPublisherMocks };
store.__pluginHookPublisherMocks__ ??= {
transaction: vi.fn(async (callback: (tx: unknown) => Promise<unknown>) => callback({ tx: true })),
publishOutboxEvent: vi.fn(async () => ({ id: "outbox_1" })),
emitPluginHook: vi.fn(async (tx, def, payload, publish, options) =>
publish(tx, {
tenantId: options?.tenantId ?? null,
type: def.outboxEventType,
payload,
dispatchKey: options?.dispatchKey ?? null,
})
),
loggerWarn: vi.fn(),
loggerError: vi.fn(),
};
return store.__pluginHookPublisherMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
$transaction: getMocks().transaction,
},
}));
vi.mock("@toolctrl/db/outbox", () => ({
publishOutboxEvent: getMocks().publishOutboxEvent,
}));
vi.mock("@toolctrl/plugin-runtime", () => ({
emitPluginHook: getMocks().emitPluginHook,
PluginHookValidationError: TestPluginHookValidationError,
}));
vi.mock("~/utils/logger.server", () => ({
createScopedLogger: () => ({
warn: getMocks().loggerWarn,
error: getMocks().loggerError,
}),
}));
import { emitPluginHookFireAndForget } from "./pluginHookPublisher";
const mocks = getMocks();
const hookDefinition = {
name: "workflow.failed",
outboxEventType: "plugin.hook.workflow.failed",
};
describe("emitPluginHookFireAndForget", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => callback({ tx: true }));
mocks.publishOutboxEvent.mockResolvedValue({ id: "outbox_1" });
mocks.emitPluginHook.mockImplementation(async (tx, def, payload, publish, options) =>
publish(tx, {
tenantId: options?.tenantId ?? null,
type: def.outboxEventType,
payload,
dispatchKey: options?.dispatchKey ?? null,
})
);
});
it("records hook publish failures in logs without failing the caller", async () => {
mocks.publishOutboxEvent.mockRejectedValueOnce(new Error("outbox unavailable"));
await expect(
emitPluginHookFireAndForget(
hookDefinition,
{ tenantId: "tenant_1", workflowId: "workflow_1" },
{ tenantId: "tenant_1", dispatchKey: "workflow-execution:execution_1:failed" }
)
).resolves.toBeUndefined();
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.publishOutboxEvent).toHaveBeenCalledTimes(1);
expect(mocks.loggerError).toHaveBeenCalledWith(
{
hookName: "workflow.failed",
tenantId: "tenant_1",
error: "outbox unavailable",
},
"plugin.hook.emit_failed"
);
});
it("publishes a plugin hook outbox event inside its one-shot transaction", async () => {
await emitPluginHookFireAndForget(
{
name: "tenant.created",
outboxEventType: "plugin.hook.tenant.created",
},
{
tenantId: "tenant_1",
slug: "acme",
ownerEmail: "owner@example.com",
plan: "free",
createdAt: "2026-05-17T10:00:00.000Z",
},
{ tenantId: "tenant_1", dispatchKey: "tenant:tenant_1" }
);
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
{ tx: true },
{
tenantId: "tenant_1",
type: "plugin.hook.tenant.created",
payload: {
tenantId: "tenant_1",
slug: "acme",
ownerEmail: "owner@example.com",
plan: "free",
createdAt: "2026-05-17T10:00:00.000Z",
},
dispatchKey: "tenant:tenant_1",
}
);
});
it("records hook validation failures in logs without failing the caller", async () => {
const issues = [{ path: ["workflowId"], message: "Required" }];
mocks.emitPluginHook.mockRejectedValueOnce(new TestPluginHookValidationError("workflow.failed", issues));
await expect(
emitPluginHookFireAndForget(hookDefinition, { tenantId: "tenant_1" }, { tenantId: "tenant_1", dispatchKey: "workflow:bad" })
).resolves.toBeUndefined();
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{
hookName: "workflow.failed",
tenantId: "tenant_1",
issues,
},
"plugin.hook.payload_invalid"
);
});
it("records transaction failures in logs without failing the caller", async () => {
mocks.transaction.mockRejectedValueOnce(new Error("db down"));
await expect(
emitPluginHookFireAndForget(
{
name: "user.created",
outboxEventType: "plugin.hook.user.created",
},
{
userId: "user_1",
tenantId: "tenant_1",
email: "u@example.com",
locale: "en",
createdAt: "2026-05-17T10:00:00.000Z",
},
{ tenantId: "tenant_1", dispatchKey: "user:user_1" }
)
).resolves.toBeUndefined();
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
expect(mocks.loggerError).toHaveBeenCalledWith(
{
hookName: "user.created",
tenantId: "tenant_1",
error: "db down",
},
"plugin.hook.emit_failed"
);
});
});
@@ -0,0 +1,86 @@
/**
* Phase 06 #22 — Host-side adapter for emitting plugin hooks.
*
* Wraps `publishOutboxEvent` + `emitPluginHook` so call-sites in the
* existing host code (tenant signup, user register, entity CRUD) stay
* a single line and never touch the outbox-row construction directly.
*
* Each emit runs inside a one-shot Prisma transaction so the outbox
* row is committed atomically with respect to itself. Hook emission is
* intentionally fire-and-forget against the surrounding business
* operation — failures are logged but never thrown, because hook
* delivery must never break tenant signup / user create / row CRUD.
*
* Foundation scope: the worker side that fans the single
* `plugin.hook.<name>` outbox event out to one invocation per
* installed plugin is part of the same task (commit 8 wires it into
* apps/worker/src/jobs/plugin-invoke.ts).
*/
import { db } from "~/utils/db.server";
import {
publishOutboxEvent,
type OutboxPublisherTransaction,
} from "@toolctrl/db/outbox";
import {
emitPluginHook,
PluginHookValidationError,
type PluginHookDefinition,
} from "@toolctrl/plugin-runtime";
import { createScopedLogger } from "~/utils/logger.server";
const pluginHookLogger = createScopedLogger("plugin-hook-publisher");
/**
* Emit a plugin hook outside an existing transaction. Use this for the
* "fire after the business write succeeded" pattern — it owns its own
* one-statement tx so the outbox row is atomic in itself, and the
* caller is decoupled from outbox failure modes.
*/
export async function emitPluginHookFireAndForget<TPayload>(
def: PluginHookDefinition<TPayload>,
payload: TPayload,
options: {
tenantId?: string | null;
dispatchKey?: string | null;
} = {}
): Promise<void> {
try {
await db.$transaction(async (tx) => {
await emitPluginHook(
tx,
def,
payload,
async (innerTx, args) => {
await publishOutboxEvent(innerTx as OutboxPublisherTransaction, {
tenantId: args.tenantId,
type: args.type,
payload: args.payload as unknown as Parameters<typeof publishOutboxEvent>[1]["payload"],
dispatchKey: args.dispatchKey,
});
},
options
);
});
} catch (error) {
if (error instanceof PluginHookValidationError) {
pluginHookLogger.warn(
{
hookName: def.name,
tenantId: options.tenantId ?? null,
issues: error.issues,
},
"plugin.hook.payload_invalid"
);
return;
}
pluginHookLogger.error(
{
hookName: def.name,
tenantId: options.tenantId ?? null,
error: error instanceof Error ? error.message : String(error),
},
"plugin.hook.emit_failed"
);
}
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vitest";
import {
MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS,
MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH,
parsePluginCapabilityGrantArray,
parsePluginCapabilityNameArray,
readPluginConsentJsonObject,
readPluginConsentUrlEncodedFormData,
} from "./capabilityConsentPayload.server";
describe("plugin capability consent payload helpers", () => {
test("parses valid capability name arrays", () => {
expect(parsePluginCapabilityNameArray(["http.outbound", "log"])).toEqual({
ok: true,
capabilityNames: ["http.outbound", "log"],
});
});
test("rejects malformed capability name arrays", () => {
const cases: unknown[] = [
[],
Array.from({ length: MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS + 1 }, (_, index) => `capability.${index}`),
["http.outbound", "http.outbound"],
[" http.outbound"],
["x".repeat(MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH + 1)],
["log", null],
];
for (const value of cases) {
expect(parsePluginCapabilityNameArray(value)).toEqual({ ok: false });
}
});
test("parses valid capability grant arrays", () => {
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }])).toEqual({
ok: true,
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
});
});
test("rejects duplicate capability grants", () => {
expect(
parsePluginCapabilityGrantArray([
{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } },
{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api2.example.com"] } },
])
).toEqual({ ok: false });
});
test("rejects unsafe capability grant scopes", () => {
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: ["api.example.com"] }])).toEqual({ ok: false });
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: { __proto__: { polluted: true } } }])).toEqual({ ok: false });
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: { note: "x".repeat(16 * 1024) } }])).toEqual({ ok: false });
});
test("reads bounded JSON objects", async () => {
const response = await readPluginConsentJsonObject(
new Request("http://localhost/plugins", {
method: "POST",
body: JSON.stringify({ pluginId: "com.acme.hello" }),
headers: { "content-type": "application/json; charset=utf-8" },
})
);
expect(response).toEqual({ ok: true, value: { pluginId: "com.acme.hello" } });
});
test("rejects JSON object bodies without an application/json content type", async () => {
for (const headers of [{ "content-type": "text/plain" }, {}]) {
await expect(
readPluginConsentJsonObject(
new Request("http://localhost/plugins", {
method: "POST",
body: JSON.stringify({ pluginId: "com.acme.hello" }),
headers,
})
)
).resolves.toEqual({ ok: false, error: "unsupported_media_type" });
}
});
test("rejects oversized request bodies before parsing", async () => {
const response = await readPluginConsentUrlEncodedFormData(
new Request("http://localhost/plugins", {
method: "POST",
body: "op=enable&id=pl-1",
headers: {
"content-type": "application/x-www-form-urlencoded",
"content-length": String(129 * 1024),
},
})
);
expect(response).toEqual({ ok: false });
});
test("accepts only exact urlencoded form content types", async () => {
const valid = await readPluginConsentUrlEncodedFormData(
new Request("http://localhost/plugins", {
method: "POST",
body: "op=enable&id=pl-1",
headers: { "content-type": "application/x-www-form-urlencoded; charset=utf-8" },
})
);
expect(valid.ok).toBe(true);
if (valid.ok) {
expect(valid.formData.get("op")).toBe("enable");
}
for (const contentType of ["text/plain; application/x-www-form-urlencoded", "application/x-www-form-urlencoded-malformed"]) {
await expect(
readPluginConsentUrlEncodedFormData(
new Request("http://localhost/plugins", {
method: "POST",
body: "op=enable&id=pl-1",
headers: { "content-type": contentType },
})
)
).resolves.toEqual({ ok: false });
}
});
});
@@ -0,0 +1,156 @@
import type { CapabilityGrant } from "~/modules/plugins/services/.server/PluginLifecycleService";
export const PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES = 128 * 1024;
export const MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS = 64;
export const MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH = 128;
const MAX_PLUGIN_CONSENT_SCOPE_BYTES = 16 * 1024;
const MAX_PLUGIN_CONSENT_SCOPE_DEPTH = 6;
const MAX_PLUGIN_CONSENT_SCOPE_NODES = 512;
const MAX_PLUGIN_CONSENT_SCOPE_ARRAY_ITEMS = 128;
const MAX_PLUGIN_CONSENT_SCOPE_OBJECT_KEYS = 128;
type JsonObjectParseError = "invalid_json" | "unsupported_media_type";
type JsonObjectResult = { ok: true; value: Record<string, unknown> } | { ok: false; error: JsonObjectParseError };
type CapabilityGrantParseResult = { ok: true; grants: CapabilityGrant[] } | { ok: false };
type CapabilityNamesParseResult = { ok: true; capabilityNames: string[] } | { ok: false };
type FormDataParseResult = { ok: true; formData: FormData } | { ok: false };
export async function readPluginConsentJsonObject(request: Request): Promise<JsonObjectResult> {
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.split(";", 1)[0]?.trim() !== "application/json") {
return { ok: false, error: "unsupported_media_type" };
}
if (isRequestBodyOverLimit(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES)) {
return { ok: false, error: "invalid_json" };
}
try {
const text = await readLimitedRequestText(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES);
if (text === null) {
return { ok: false, error: "invalid_json" };
}
const value = JSON.parse(text) as unknown;
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, error: "invalid_json" };
}
return { ok: true, value: value as Record<string, unknown> };
} catch {
return { ok: false, error: "invalid_json" };
}
}
export async function readPluginConsentUrlEncodedFormData(request: Request): Promise<FormDataParseResult> {
if (isRequestBodyOverLimit(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES)) {
return { ok: false };
}
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.split(";", 1)[0]?.trim() !== "application/x-www-form-urlencoded") {
return { ok: false };
}
const text = await readLimitedRequestText(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES);
if (text === null) {
return { ok: false };
}
const formData = new FormData();
for (const [key, value] of new URLSearchParams(text)) {
formData.append(key, value);
}
return { ok: true, formData };
}
export function parsePluginCapabilityGrantArray(value: unknown): CapabilityGrantParseResult {
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS) return { ok: false };
const grants: CapabilityGrant[] = [];
const seen = new Set<string>();
for (const grant of value) {
if (!isPlainObject(grant)) return { ok: false };
const capabilityName = grant.capabilityName;
if (!isValidCapabilityName(capabilityName) || seen.has(capabilityName)) {
return { ok: false };
}
seen.add(capabilityName);
const scopeJson = parseGrantScope(grant.scopeJson);
if (scopeJson === undefined) return { ok: false };
grants.push({ capabilityName, scopeJson });
}
return { ok: true, grants };
}
export function parsePluginCapabilityNameArray(value: unknown): CapabilityNamesParseResult {
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS) return { ok: false };
const names: string[] = [];
const seen = new Set<string>();
for (const name of value) {
if (!isValidCapabilityName(name) || seen.has(name)) {
return { ok: false };
}
seen.add(name);
names.push(name);
}
return { ok: true, capabilityNames: names };
}
function isRequestBodyOverLimit(request: Request, maxBytes: number): boolean {
const contentLength = Number(request.headers.get("content-length") ?? "0");
return Number.isFinite(contentLength) && contentLength > maxBytes;
}
async function readLimitedRequestText(request: Request, maxBytes: number): Promise<string | null> {
if (!request.body) return "";
const reader = request.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
await reader.cancel();
return null;
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return text;
} finally {
reader.releaseLock();
}
}
function isValidCapabilityName(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH && value.trim() === value;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function isSafeJsonValue(value: unknown, depth = 0, budget = { nodes: 0 }): boolean {
budget.nodes += 1;
if (budget.nodes > MAX_PLUGIN_CONSENT_SCOPE_NODES || depth > MAX_PLUGIN_CONSENT_SCOPE_DEPTH) return false;
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) {
return value.length <= MAX_PLUGIN_CONSENT_SCOPE_ARRAY_ITEMS && value.every((item) => isSafeJsonValue(item, depth + 1, budget));
}
if (isPlainObject(value)) {
const entries = Object.entries(value);
return (
entries.length <= MAX_PLUGIN_CONSENT_SCOPE_OBJECT_KEYS &&
entries.every(([key, nested]) => key !== "__proto__" && key !== "constructor" && key !== "prototype" && isSafeJsonValue(nested, depth + 1, budget))
);
}
return false;
}
function parseGrantScope(value: unknown): unknown | undefined {
if (value === undefined || value === null) return null;
if (!isPlainObject(value)) return undefined;
const encoded = JSON.stringify(value);
if (encoded.length > MAX_PLUGIN_CONSENT_SCOPE_BYTES || !isSafeJsonValue(value)) return undefined;
return value;
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { createCapabilityReviewDigest } from "./capabilityReviewDigest";
function plugin(overrides: Partial<{ pluginId: string; version: string; scope: string | null }> = {}) {
return {
pluginId: overrides.pluginId ?? "com.example.digest",
version: overrides.version ?? "1.0.0",
manifest: {
capabilities: [
{ name: "secret.read", scope: overrides.scope ?? "tenant" },
{ name: "http.outbound", scope: "tenant" },
{ name: "log", scope: null },
],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
},
};
}
describe("createCapabilityReviewDigest", () => {
it("is stable regardless of requested capability order", () => {
expect(createCapabilityReviewDigest(plugin(), ["secret.read", "http.outbound"])).toBe(
createCapabilityReviewDigest(plugin(), ["http.outbound", "secret.read"])
);
});
it("only binds selected capabilities into the review digest", () => {
const selected = createCapabilityReviewDigest(plugin(), ["log"]);
const selectedWithUnmanifested = createCapabilityReviewDigest(plugin(), ["log", "db.write"]);
const broaderGrant = createCapabilityReviewDigest(plugin(), ["log", "secret.read"]);
expect(selectedWithUnmanifested).toBe(selected);
expect(broaderGrant).not.toBe(selected);
});
it("binds manifest hook consent items into the review digest", () => {
const hookReviewDigest = createCapabilityReviewDigest(plugin(), ["hook:workflow.executed"]);
const capabilityReviewDigest = createCapabilityReviewDigest(plugin(), ["log"]);
const combinedReviewDigest = createCapabilityReviewDigest(plugin(), ["log", "hook:workflow.executed"]);
expect(hookReviewDigest).not.toBe(capabilityReviewDigest);
expect(combinedReviewDigest).not.toBe(capabilityReviewDigest);
expect(combinedReviewDigest).not.toBe(hookReviewDigest);
});
it("does not allow a partial review digest to stand in for a broader submitted grant set", () => {
const partialReviewDigest = createCapabilityReviewDigest(plugin(), ["log"]);
const submittedGrantSetDigest = createCapabilityReviewDigest(plugin(), ["log", "secret.read"]);
expect(submittedGrantSetDigest).not.toBe(partialReviewDigest);
});
it("changes when plugin identity version or capability scope changes", () => {
const baseline = createCapabilityReviewDigest(plugin(), ["secret.read"]);
expect(createCapabilityReviewDigest(plugin({ pluginId: "com.example.other" }), ["secret.read"])).not.toBe(baseline);
expect(createCapabilityReviewDigest(plugin({ version: "1.0.1" }), ["secret.read"])).not.toBe(baseline);
expect(createCapabilityReviewDigest(plugin({ scope: "workspace" }), ["secret.read"])).not.toBe(baseline);
});
});
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import type { PluginListItem } from "../services/.server/PluginLifecycleService";
import { getPluginConsentRequirements } from "./pluginConsentRequirements";
export function createCapabilityReviewDigest(item: Pick<PluginListItem, "pluginId" | "version" | "manifest">, capabilityNames: ReadonlyArray<string>) {
const selectedCapabilities = new Set(capabilityNames);
const reviewPayload = {
pluginId: item.pluginId,
version: item.version,
capabilities: getPluginConsentRequirements(item.manifest)
.filter((requirement) => selectedCapabilities.has(requirement.name))
.map((requirement) => ({
name: requirement.name,
scope: requirement.scope ?? null,
kind: requirement.kind,
}))
.sort((left, right) => left.name.localeCompare(right.name)),
};
return createHash("sha256").update(stableJsonStringify(reviewPayload)).digest("base64url");
}
function stableJsonStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableJsonStringify(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableJsonStringify(nestedValue)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
@@ -0,0 +1,32 @@
import type { PluginManifest } from "@toolctrl/plugin-runtime/manifest";
export const PLUGIN_HOOK_CONSENT_PREFIX = "hook:";
export type PluginConsentRequirement = {
name: string;
scope: PluginManifest["capabilities"][number]["scope"] | null;
kind: "capability" | "hook";
};
export function getPluginHookConsentName(hookName: string): string {
return `${PLUGIN_HOOK_CONSENT_PREFIX}${hookName}`;
}
export function isPluginHookConsentName(name: string): boolean {
return name.startsWith(PLUGIN_HOOK_CONSENT_PREFIX);
}
export function getPluginConsentRequirements(manifest: PluginManifest): PluginConsentRequirement[] {
return [
...(manifest.capabilities ?? []).map((capability) => ({
name: capability.name as string,
scope: capability.scope ?? null,
kind: "capability" as const,
})),
...(manifest.hooks ?? []).map((hook) => ({
name: getPluginHookConsentName(hook.name),
scope: null,
kind: "hook" as const,
})),
];
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
PLUGIN_SETTING_ARRAY_MAX_LENGTH,
PLUGIN_SETTING_KEY_MAX_LENGTH,
PLUGIN_SETTING_OBJECT_MAX_KEYS,
PLUGIN_SETTING_VALUE_MAX_BYTES,
isPluginSettingKeyFormatSafe,
isPluginSettingKeyReserved,
isPluginSettingKeySecretLike,
isPluginSettingValue,
isPluginSettingValueShapeSafe,
isPluginSettingValueWithinSize,
isSafePluginSettingKey,
} from "./pluginSettingsValidation";
describe("plugin settings validation", () => {
it("keeps frame and persistence setting keys on the same safe-key contract", () => {
expect(isSafePluginSettingKey("theme.accent:color-1")).toBe(true);
expect(isPluginSettingKeyFormatSafe("k".repeat(PLUGIN_SETTING_KEY_MAX_LENGTH))).toBe(true);
expect(isPluginSettingKeyFormatSafe("k".repeat(PLUGIN_SETTING_KEY_MAX_LENGTH + 1))).toBe(false);
for (const key of [
"../theme",
"__proto__",
"constructor",
"prototype",
"apiKey",
"access-token",
"password",
"secret.value",
"authorization",
"credentials",
"auth.header",
"bearer",
"cookie",
"sessionId",
"private_key",
"clientSecret",
"webhookUrl",
]) {
expect(isSafePluginSettingKey(key)).toBe(false);
}
expect(isPluginSettingKeyReserved("__proto__")).toBe(true);
expect(isPluginSettingKeySecretLike("refresh-token")).toBe(true);
expect(isPluginSettingKeySecretLike("authorization")).toBe(true);
expect(isPluginSettingKeySecretLike("clientSecret")).toBe(true);
});
it("rejects values that cannot cross the iframe-to-persistence boundary safely", () => {
expect(isPluginSettingValue({ theme: "dark", flags: [true, false], count: 2 })).toBe(true);
expect(isPluginSettingValue(Number.NaN)).toBe(false);
expect(isPluginSettingValue(Array.from({ length: PLUGIN_SETTING_ARRAY_MAX_LENGTH + 1 }, () => true))).toBe(false);
expect(isPluginSettingValue(Object.fromEntries(Array.from({ length: PLUGIN_SETTING_OBJECT_MAX_KEYS + 1 }, (_, index) => [`key${index}`, true])))).toBe(
false
);
expect(isPluginSettingValue(JSON.parse('{"theme":"dark","__proto__":{"polluted":true}}'))).toBe(false);
expect(isPluginSettingValue({ theme: { accessToken: "must-not-persist" } })).toBe(false);
expect(isPluginSettingValue({ request: { authorization: "Bearer must-not-persist" } })).toBe(false);
expect(isPluginSettingValue({ endpoint: { webhookUrl: "https://hooks.example.test/secret" } })).toBe(false);
});
it("separates JSON shape errors from serialized size errors for server error mapping", () => {
const oversized = "x".repeat(PLUGIN_SETTING_VALUE_MAX_BYTES);
expect(isPluginSettingValueShapeSafe(oversized)).toBe(true);
expect(isPluginSettingValueWithinSize(oversized)).toBe(false);
expect(isPluginSettingValue(oversized)).toBe(false);
});
});
@@ -0,0 +1,73 @@
export const PLUGIN_SETTING_KEY_MAX_LENGTH = 128;
export const PLUGIN_SETTING_VALUE_MAX_BYTES = 32 * 1024;
export const PLUGIN_SETTING_VALUE_MAX_DEPTH = 8;
export const PLUGIN_SETTING_VALUE_MAX_NODES = 2048;
export const PLUGIN_SETTING_ARRAY_MAX_LENGTH = 512;
export const PLUGIN_SETTING_OBJECT_MAX_KEYS = 256;
const SAFE_PLUGIN_SETTING_KEY_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/u;
const SECRET_LIKE_SETTING_KEY_PATTERN =
/(?:secret|token|password|credential|authorization|auth[-_.:]|[-_.:]auth(?:$|[-_.:])|bearer|cookie|session|private[-_.:]?key|client[-_.:]?secret|webhook|apikey|api[-_.:]?key)/iu;
const RESERVED_PLUGIN_SETTING_KEYS = new Set(["__proto__", "constructor", "prototype"]);
export type PluginSettingValue = null | string | number | boolean | PluginSettingValue[] | { readonly [key: string]: PluginSettingValue };
export function isPluginSettingKeyFormatSafe(value: unknown): value is string {
return typeof value === "string" && SAFE_PLUGIN_SETTING_KEY_PATTERN.test(value);
}
export function isPluginSettingKeyReserved(value: string): boolean {
return RESERVED_PLUGIN_SETTING_KEYS.has(value);
}
export function isPluginSettingKeySecretLike(value: string): boolean {
return SECRET_LIKE_SETTING_KEY_PATTERN.test(value);
}
export function isSafePluginSettingKey(value: unknown): value is string {
return isPluginSettingKeyFormatSafe(value) && !isPluginSettingKeyReserved(value) && !isPluginSettingKeySecretLike(value);
}
export function isPluginSettingValue(value: unknown): value is PluginSettingValue {
return isPluginSettingValueShapeSafe(value) && isPluginSettingValueWithinSize(value);
}
export function isPluginSettingValueShapeSafe(
value: unknown,
depth = 0,
budget: { nodes: number; seen: WeakSet<object> } = { nodes: 0, seen: new WeakSet<object>() }
): value is PluginSettingValue {
budget.nodes += 1;
if (depth > PLUGIN_SETTING_VALUE_MAX_DEPTH || budget.nodes > PLUGIN_SETTING_VALUE_MAX_NODES) return false;
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value === "object" && value !== null) {
if (budget.seen.has(value)) return false;
budget.seen.add(value);
}
if (Array.isArray(value)) {
if (value.length > PLUGIN_SETTING_ARRAY_MAX_LENGTH) return false;
return value.every((item) => isPluginSettingValueShapeSafe(item, depth + 1, budget));
}
if (!isPlainPluginSettingsObject(value)) return false;
const entries = Object.entries(value);
if (entries.length > PLUGIN_SETTING_OBJECT_MAX_KEYS) return false;
return entries.every(
([key, nested]) => !isPluginSettingKeyReserved(key) && !isPluginSettingKeySecretLike(key) && isPluginSettingValueShapeSafe(nested, depth + 1, budget)
);
}
export function isPluginSettingValueWithinSize(value: PluginSettingValue): boolean {
return getUtf8ByteLength(JSON.stringify(value)) <= PLUGIN_SETTING_VALUE_MAX_BYTES;
}
function isPlainPluginSettingsObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function getUtf8ByteLength(value: string): number {
return new TextEncoder().encode(value).length;
}