Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
+544
@@ -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();
|
||||
});
|
||||
});
|
||||
+1104
File diff suppressed because it is too large.
Load diff
+762
@@ -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,
|
||||
};
|
||||
}
|
||||
+517
@@ -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();
|
||||
});
|
||||
});
|
||||
+342
@@ -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.`);
|
||||
}
|
||||
}
|
||||
+198
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+122
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user