From 161ec56bf0500017369a30fa2507d707b7fb8c36 Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Fri, 19 Jun 2026 20:57:15 +0000 Subject: [PATCH] Extract plugin manifest shape validation seam --- apps/desktop/scripts/run-tests.mjs | 1 + apps/desktop/src/check-packaging-contract.ts | 12 ++ apps/desktop/src/codemap.md | 3 +- .../src/plugin-manifest-shape-validation.ts | 119 +++++++++++++++++ apps/desktop/src/plugin-manifest.ts | 124 +++--------------- .../plugin-manifest-shape-validation.test.ts | 22 ++++ .../plugin-manifest-validation-seams.test.ts | 2 +- 7 files changed, 174 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/src/plugin-manifest-shape-validation.ts create mode 100644 apps/desktop/tests/plugin-manifest-shape-validation.test.ts diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index 75880327..a77fc061 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -113,6 +113,7 @@ const behaviorTests = [ ".test-dist/tests/plugin-service-local-support.test.js", ".test-dist/tests/plugin-service-ui-helpers.test.js", ".test-dist/tests/plugin-manifest-validation-seams.test.js", + ".test-dist/tests/plugin-manifest-shape-validation.test.js", ".test-dist/tests/plugins-view-config-list-editor.test.js", ".test-dist/tests/plugins-view-config-fields.test.js", ".test-dist/tests/plugin-ui-static.test.js", diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index b291321f..84ed0612 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -223,6 +223,7 @@ const pluginServiceSnapshotSource = readFileSync(join(appDir, "src", "plugin-ser const pluginServiceTextHelpersSource = readFileSync(join(appDir, "src", "plugin-service-text-helpers.ts"), "utf8"); const pluginManifestSource = readFileSync(join(appDir, "src", "plugin-manifest.ts"), "utf8"); const pluginManifestConfigValidationSource = readFileSync(join(appDir, "src", "plugin-manifest-config-validation.ts"), "utf8"); +const pluginManifestShapeValidationSource = readFileSync(join(appDir, "src", "plugin-manifest-shape-validation.ts"), "utf8"); const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8"); const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8"); const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8"); @@ -587,8 +588,19 @@ assert.match(pluginServiceSnapshotSource, /export function validatePluginService assert.match(pluginServiceTextHelpersSource, /export async function ensurePluginCommandLocale/, "plugin-service text helper must export plugin locale preloading."); assert.match(pluginServiceTextHelpersSource, /export function safeCommandError/, "plugin-service text helper must export command error sanitization."); assert.match(pluginManifestSource, /from "\.\/plugin-manifest-config-validation(?:\.js)?"/, "plugin-manifest must import the extracted config/action validation seam."); +assert.match(pluginManifestSource, /from "\.\/plugin-manifest-shape-validation(?:\.js)?"/, "plugin-manifest must import the extracted shape validation seam."); assert.match(pluginManifestConfigValidationSource, /export function validateConfigSchema/, "plugin-manifest config validation seam must export config schema validation."); assert.match(pluginManifestConfigValidationSource, /export function validateTriggers/, "plugin-manifest config validation seam must export trigger/action validation."); +assert.match(pluginManifestShapeValidationSource, /export function validateAssets/, "plugin-manifest shape validation seam must export asset validation."); +assert.match(pluginManifestShapeValidationSource, /export function validatePanels/, "plugin-manifest shape validation seam must export panel validation."); +assert.match(pluginManifestShapeValidationSource, /export function validatePluginIcon/, "plugin-manifest shape validation seam must export icon validation."); +assert.match(pluginManifestShapeValidationSource, /export function validateEntryPath/, "plugin-manifest shape validation seam must export entry path validation."); +assert.match(pluginManifestShapeValidationSource, /export function validateNetwork/, "plugin-manifest shape validation seam must export network validation."); +assert.match(pluginManifestShapeValidationSource, /export function validatePermissions/, "plugin-manifest shape validation seam must export shared permission validation."); +assert.match(pluginManifestShapeValidationSource, /export function rejectUnknownFields/, "plugin-manifest shape validation seam must export unknown-field rejection."); +assert.match(pluginManifestShapeValidationSource, /export function validateString/, "plugin-manifest shape validation seam must export string validation."); +assert.match(pluginManifestShapeValidationSource, /export function addError/, "plugin-manifest shape validation seam must export manifest error shaping."); +assert.match(pluginManifestShapeValidationSource, /export function isRecord/, "plugin-manifest shape validation seam must export record guards."); assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-presentation"/, "plugins route must import the extracted presentation seam."); assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-config-helpers"/, "plugins route must import the extracted config helper seam."); assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-state"/, "plugins route must import the extracted route state seam."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 82a71941..e3af934d 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -290,8 +290,9 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection) **Plugins**: -- `plugin-manifest.ts`: Manifest V1/V2 schema/types plus the top-level declarative/JavaScript entrypoint validation, permissions, asset/panel limits, and exported manifest constants. +- `plugin-manifest.ts`: Manifest V1/V2 schema/types plus the top-level declarative/JavaScript entrypoint validation, permissions, and exported manifest constants. - `plugin-manifest-config-validation.ts`: Extracted config-schema plus timer trigger/action validation for plugin-manifest, including reaction-select config checks. +- `plugin-manifest-shape-validation.ts`: Extracted manifest shape validation for shared string/object guards, entry/icon/network checks, and v3 asset/panel declarations. - `plugin-manifest-reader.ts`: Safe manifest reader with realpath/allowed-root checks, root filename enforcement, size limit, and expected id/version matching. - `plugin-config.ts`: Config defaulting, replacement validation, and runtime resolution for string/number config references. - `plugin-state.ts`: Persistent plugin state store (`familiaros-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason. diff --git a/apps/desktop/src/plugin-manifest-shape-validation.ts b/apps/desktop/src/plugin-manifest-shape-validation.ts new file mode 100644 index 00000000..3c4e1583 --- /dev/null +++ b/apps/desktop/src/plugin-manifest-shape-validation.ts @@ -0,0 +1,119 @@ +type PluginAssetKind = "icons" | "images" | "svgs" | "sprites" | "sounds"; +type PluginManifestValidationError = { + path: string; + code: string; + message: string; +}; + +type PluginAssetValidationOptions = { + readonly assetKinds: readonly PluginAssetKind[]; + readonly assetExtensions: Record; + readonly assetNamePattern: RegExp; +}; + +export function validateAssets(value: unknown, errors: PluginManifestValidationError[], options: PluginAssetValidationOptions): void { + if (value === undefined) return; + if (!isRecord(value)) return addError(errors, "$.assets", "invalid_assets", "assets must be an object."); + rejectUnknownFields(value, new Set(options.assetKinds), "$.assets", errors); + for (const kind of options.assetKinds) { + const group = value[kind]; + if (group === undefined) continue; + if (!isRecord(group)) { + addError(errors, `$.assets.${kind}`, "invalid_assets", `assets.${kind} must be an object.`); + continue; + } + const entries = Object.entries(group); + if (entries.length > 32) addError(errors, `$.assets.${kind}`, "too_many_assets", `assets.${kind} may declare at most 32 entries.`); + for (const [name, assetPath] of entries) { + const path = `$.assets.${kind}.${name}`; + if (!options.assetNamePattern.test(name)) addError(errors, path, "invalid_asset_name", "Asset names must be simple lowercase identifiers."); + validateRelativeAssetPath(assetPath, options.assetExtensions[kind], path, errors); + } + } +} + +export function validatePanels(value: unknown, errors: PluginManifestValidationError[], assetNamePattern: RegExp): void { + if (value === undefined) return; + if (!isRecord(value)) return addError(errors, "$.panels", "invalid_panels", "panels must be an object."); + const entries = Object.entries(value); + if (entries.length > 8) addError(errors, "$.panels", "too_many_panels", "panels may declare at most 8 entries."); + for (const [name, panelPath] of entries) { + const path = `$.panels.${name}`; + if (!assetNamePattern.test(name)) addError(errors, path, "invalid_panel_name", "Panel names must be simple lowercase identifiers."); + validateRelativeAssetPath(panelPath, [".html"], path, errors); + } +} + +export function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[], supportedPluginIcons: ReadonlySet): void { + if (value === undefined) return; + if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, github, heart, sparkles, coffee, focus, or droplet."); +} + +export function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void { + validateString(value, "$.entry", "entry", errors); + if (typeof value !== "string") return; + if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || !/\.(?:mjs|js)$/.test(value)) addError(errors, "$.entry", "invalid_entry", "entry must be a relative .js or .mjs path."); +} + +export function validateNetwork(value: unknown, errors: PluginManifestValidationError[]): void { + if (value === undefined) return; + if (!isRecord(value) || !Array.isArray(value.hosts)) return addError(errors, "$.network.hosts", "invalid_network_hosts", "network.hosts must be an array."); + rejectUnknownFields(value, new Set(["hosts"]), "$.network", errors); + const hosts = value.hosts; + hosts.forEach((host, index) => { + if (typeof host !== "string" || !/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host) || host.includes("*") || host.trim() !== host) { + addError(errors, `$.network.hosts[${index}]`, "invalid_network_host", "network hosts must be exact host names."); + } else if (hosts.indexOf(host) !== index) { + addError(errors, `$.network.hosts[${index}]`, "duplicate_network_host", "Duplicate network host."); + } + }); +} + +export function validatePermissions(value: unknown, errors: PluginManifestValidationError[], allowedPermissions: ReadonlySet): Set { + const permissions = new Set(); + if (!Array.isArray(value)) { + addError(errors, "$.permissions", "invalid_permissions", "permissions must be an array."); + return permissions; + } + value.forEach((permission, index) => { + if (typeof permission !== "string" || !allowedPermissions.has(permission)) { + addError(errors, `$.permissions[${index}]`, "invalid_permission", `Permission must be one of ${[...allowedPermissions].join(", ")}.`); + return; + } + if (permissions.has(permission)) { + addError(errors, `$.permissions[${index}]`, "duplicate_permission", `Duplicate permission ${permission}.`); + return; + } + permissions.add(permission); + }); + return permissions; +} + +export function rejectUnknownFields(record: Record, allowed: Set, path: string, errors: PluginManifestValidationError[]): void { + for (const key of Object.keys(record)) { + if (!allowed.has(key)) addError(errors, `${path}.${key}`, "unknown_field", `Unknown field ${key}.`); + } +} + +export function validateString(value: unknown, path: string, label: string, errors: PluginManifestValidationError[], pattern?: RegExp): void { + if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_string", `${label} must be a non-empty string.`); + if (pattern && !pattern.test(value)) addError(errors, path, "invalid_format", `${label} has an invalid format.`); +} + +export function addError(errors: PluginManifestValidationError[], path: string, code: string, message: string): void { + errors.push({ path, code, message }); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateRelativeAssetPath(value: unknown, allowedExtensions: readonly string[], path: string, errors: PluginManifestValidationError[]): void { + if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_asset_path", "Asset path must be a non-empty string."); + if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || value.split("/").includes(".")) { + return addError(errors, path, "invalid_asset_path", "Asset path must be a safe relative path."); + } + if (!allowedExtensions.some((extension) => value.toLowerCase().endsWith(extension))) { + addError(errors, path, "invalid_asset_format", `Asset must end with one of ${allowedExtensions.join(", ")}.`); + } +} diff --git a/apps/desktop/src/plugin-manifest.ts b/apps/desktop/src/plugin-manifest.ts index eed40791..564973be 100644 --- a/apps/desktop/src/plugin-manifest.ts +++ b/apps/desktop/src/plugin-manifest.ts @@ -2,6 +2,18 @@ import { validateConfigSchema, validateTriggers, } from "./plugin-manifest-config-validation.js"; +import { + addError, + isRecord, + rejectUnknownFields, + validateAssets, + validateEntryPath, + validateNetwork, + validatePanels, + validatePermissions, + validatePluginIcon, + validateString, +} from "./plugin-manifest-shape-validation.js"; export const FAMILIAROS_PLUGIN_MANIFEST_FILENAME = "familiaros.plugin.json"; export const openPetsPluginManifestFilename = FAMILIAROS_PLUGIN_MANIFEST_FILENAME; @@ -195,7 +207,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation validateString(input.name, "$.name", "name", errors); if (input.description !== undefined) validateString(input.description, "$.description", "description", errors); validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); - validatePluginIcon(input.icon, errors); + validatePluginIcon(input.icon, errors, supportedPluginIcons); if (input.runtime === "javascript") { addError(errors, "$.runtime", "unsupported_runtime", 'Runtime "javascript" is recognized but unsupported in manifest v1. Use "declarative".'); @@ -218,7 +230,7 @@ function validateJavascriptPluginManifest(input: Record, manife validateString(input.name, "$.name", "name", errors); if (input.description !== undefined) validateString(input.description, "$.description", "description", errors); validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); - validatePluginIcon(input.icon, errors); + validatePluginIcon(input.icon, errors, supportedPluginIcons); if (input.runtime !== "javascript") addError(errors, "$.runtime", "invalid_runtime", `manifestVersion ${manifestVersion} runtime must be "javascript".`); validateString(input.sdkVersion, "$.sdkVersion", "sdkVersion", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); if (manifestVersion === 3 && typeof input.sdkVersion === "string" && !input.sdkVersion.startsWith("3.")) { @@ -229,8 +241,8 @@ function validateJavascriptPluginManifest(input: Record, manife validateConfigSchema(input.configSchema, errors, manifestVersion); validateNetwork(input.network, errors); if (manifestVersion === 3) { - validateAssets(input.assets, errors); - validatePanels(input.panels, errors); + validateAssets(input.assets, errors, { assetKinds: pluginAssetKinds, assetExtensions: pluginAssetExtensions, assetNamePattern }); + validatePanels(input.panels, errors, assetNamePattern); } if (errors.length > 0) return { ok: false, errors }; return { ok: true, manifest: input as FamiliarOSPluginManifest, errors: [] }; @@ -238,109 +250,7 @@ function validateJavascriptPluginManifest(input: Record, manife function validateJavascriptPermissions(value: unknown, manifestVersion: 2 | 3, errors: PluginManifestValidationError[]): Set { const allowed = new Set(manifestVersion === 3 ? javascriptPluginPermissionsV3 : javascriptPluginPermissionsV2); - const permissions = validatePermissions(value, errors); + const permissions = validatePermissions(value, errors, allowed); for (const permission of permissions) if (!allowed.has(permission)) addError(errors, "$.permissions", "invalid_permission", `Permission ${permission} is not valid for manifestVersion ${manifestVersion} javascript plugins.`); return permissions; } - -function validateRelativeAssetPath(value: unknown, allowedExtensions: readonly string[], path: string, errors: PluginManifestValidationError[]): void { - if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_asset_path", "Asset path must be a non-empty string."); - if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || value.split("/").includes(".")) { - return addError(errors, path, "invalid_asset_path", "Asset path must be a safe relative path."); - } - if (!allowedExtensions.some((extension) => value.toLowerCase().endsWith(extension))) { - addError(errors, path, "invalid_asset_format", `Asset must end with one of ${allowedExtensions.join(", ")}.`); - } -} - -function validateAssets(value: unknown, errors: PluginManifestValidationError[]): void { - if (value === undefined) return; - if (!isRecord(value)) return addError(errors, "$.assets", "invalid_assets", "assets must be an object."); - rejectUnknownFields(value, new Set(pluginAssetKinds), "$.assets", errors); - for (const kind of pluginAssetKinds) { - const group = value[kind]; - if (group === undefined) continue; - if (!isRecord(group)) { addError(errors, `$.assets.${kind}`, "invalid_assets", `assets.${kind} must be an object.`); continue; } - const entries = Object.entries(group); - if (entries.length > 32) addError(errors, `$.assets.${kind}`, "too_many_assets", `assets.${kind} may declare at most 32 entries.`); - for (const [name, assetPath] of entries) { - const path = `$.assets.${kind}.${name}`; - if (!assetNamePattern.test(name)) addError(errors, path, "invalid_asset_name", "Asset names must be simple lowercase identifiers."); - validateRelativeAssetPath(assetPath, pluginAssetExtensions[kind], path, errors); - } - } -} - -function validatePanels(value: unknown, errors: PluginManifestValidationError[]): void { - if (value === undefined) return; - if (!isRecord(value)) return addError(errors, "$.panels", "invalid_panels", "panels must be an object."); - const entries = Object.entries(value); - if (entries.length > 8) addError(errors, "$.panels", "too_many_panels", "panels may declare at most 8 entries."); - for (const [name, panelPath] of entries) { - const path = `$.panels.${name}`; - if (!assetNamePattern.test(name)) addError(errors, path, "invalid_panel_name", "Panel names must be simple lowercase identifiers."); - validateRelativeAssetPath(panelPath, [".html"], path, errors); - } -} - -function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[]): void { - if (value === undefined) return; - if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, github, heart, sparkles, coffee, focus, or droplet."); -} - -function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void { - validateString(value, "$.entry", "entry", errors); - if (typeof value !== "string") return; - if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || !/\.(?:mjs|js)$/.test(value)) addError(errors, "$.entry", "invalid_entry", "entry must be a relative .js or .mjs path."); -} - -function validateNetwork(value: unknown, errors: PluginManifestValidationError[]): void { - if (value === undefined) return; - if (!isRecord(value) || !Array.isArray(value.hosts)) return addError(errors, "$.network.hosts", "invalid_network_hosts", "network.hosts must be an array."); - rejectUnknownFields(value, new Set(["hosts"]), "$.network", errors); - const seen = new Set(); - value.hosts.forEach((host, index) => { - if (typeof host !== "string" || !/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host) || host.includes("*") || host.trim() !== host) addError(errors, `$.network.hosts[${index}]`, "invalid_network_host", "network hosts must be exact host names."); - else if (seen.has(host)) addError(errors, `$.network.hosts[${index}]`, "duplicate_network_host", "Duplicate network host."); - seen.add(String(host)); - }); -} - -function validatePermissions(value: unknown, errors: PluginManifestValidationError[], allowedPermissions: ReadonlySet = pluginPermissionSet): Set { - const permissions = new Set(); - if (!Array.isArray(value)) { - addError(errors, "$.permissions", "invalid_permissions", "permissions must be an array."); - return permissions; - } - value.forEach((permission, index) => { - if (typeof permission !== "string" || !allowedPermissions.has(permission)) { - addError(errors, `$.permissions[${index}]`, "invalid_permission", `Permission must be one of ${[...allowedPermissions].join(", ")}.`); - return; - } - if (permissions.has(permission)) { - addError(errors, `$.permissions[${index}]`, "duplicate_permission", `Duplicate permission ${permission}.`); - return; - } - permissions.add(permission); - }); - return permissions; -} - -function rejectUnknownFields(record: Record, allowed: Set, path: string, errors: PluginManifestValidationError[]): void { - for (const key of Object.keys(record)) { - if (!allowed.has(key)) addError(errors, `${path}.${key}`, "unknown_field", `Unknown field ${key}.`); - } -} - -function validateString(value: unknown, path: string, label: string, errors: PluginManifestValidationError[], pattern?: RegExp): void { - if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_string", `${label} must be a non-empty string.`); - if (pattern && !pattern.test(value)) addError(errors, path, "invalid_format", `${label} has an invalid format.`); -} - -function addError(errors: PluginManifestValidationError[], path: string, code: string, message: string): void { - errors.push({ path, code, message }); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/apps/desktop/tests/plugin-manifest-shape-validation.test.ts b/apps/desktop/tests/plugin-manifest-shape-validation.test.ts new file mode 100644 index 00000000..085c2221 --- /dev/null +++ b/apps/desktop/tests/plugin-manifest-shape-validation.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pluginManifestSource = readFileSync(resolve(desktopRoot, "src/plugin-manifest.ts"), "utf8"); +const pluginManifestShapeValidationSource = readFileSync(resolve(desktopRoot, "src/plugin-manifest-shape-validation.ts"), "utf8"); + +assert.match(pluginManifestSource, /from "\.\/plugin-manifest-shape-validation(?:\.js)?"/, "plugin-manifest must import the extracted shape validation seam."); +assert.match(pluginManifestShapeValidationSource, /export function validateAssets/, "plugin-manifest shape validation seam must export asset validation."); +assert.match(pluginManifestShapeValidationSource, /export function validatePanels/, "plugin-manifest shape validation seam must export panel validation."); +assert.match(pluginManifestShapeValidationSource, /export function validatePluginIcon/, "plugin-manifest shape validation seam must export icon validation."); +assert.match(pluginManifestShapeValidationSource, /export function validateEntryPath/, "plugin-manifest shape validation seam must export entry path validation."); +assert.match(pluginManifestShapeValidationSource, /export function validateNetwork/, "plugin-manifest shape validation seam must export network validation."); +assert.match(pluginManifestShapeValidationSource, /export function validatePermissions/, "plugin-manifest shape validation seam must export shared permission validation."); +assert.match(pluginManifestShapeValidationSource, /export function rejectUnknownFields/, "plugin-manifest shape validation seam must export unknown-field rejection."); +assert.match(pluginManifestShapeValidationSource, /export function validateString/, "plugin-manifest shape validation seam must export string validation."); +assert.match(pluginManifestShapeValidationSource, /export function addError/, "plugin-manifest shape validation seam must export manifest error shaping."); +assert.match(pluginManifestShapeValidationSource, /export function isRecord/, "plugin-manifest shape validation seam must export record guards."); + +console.error("Plugin manifest shape validation seam passed."); diff --git a/apps/desktop/tests/plugin-manifest-validation-seams.test.ts b/apps/desktop/tests/plugin-manifest-validation-seams.test.ts index 64ba3fd0..7a10ee97 100644 --- a/apps/desktop/tests/plugin-manifest-validation-seams.test.ts +++ b/apps/desktop/tests/plugin-manifest-validation-seams.test.ts @@ -12,4 +12,4 @@ assert.match(pluginManifestConfigValidationSource, /export function validateConf assert.match(pluginManifestConfigValidationSource, /export function validateTriggers/, "plugin-manifest config validation seam must export trigger and action validation."); assert.match(pluginManifestConfigValidationSource, /allowedReactions/, "plugin-manifest config validation seam must own reaction-select validation."); -console.error("Plugin manifest validation seam passed."); +console.error("Plugin manifest config validation seam passed.");