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
+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.`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user