Track HiveGrid Cloud planning reference

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

No files matched your search

@@ -0,0 +1,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;
}