From 42471ccc1c4cfe4fc00cc3718f7a542ff1e1ae31 Mon Sep 17 00:00:00 2001 From: Balaji R Date: Thu, 7 May 2026 01:56:03 +0530 Subject: [PATCH] feat(#709): validate plugin config with manifest zod schema --- docs/plugins/api-reference.mdx | 20 ++++- docs/plugins/creating-plugins.mdx | 7 +- docs/plugins/overview.mdx | 17 ++++ packages/api/package.json | 3 +- packages/api/src/types.ts | 20 +++-- .../create-plugin/src/templates/index-ts.ts | 13 ++- .../src/templates/package-json.ts | 1 + .../src/__tests__/loader.test.ts | 39 +++++++++ .../src/__tests__/runner.test.ts | 83 +++++++++++++++++++ packages/plugin-runner/src/loader.ts | 12 +++ packages/plugin-runner/src/runner.ts | 34 +++++++- pnpm-lock.yaml | 3 + 12 files changed, 239 insertions(+), 13 deletions(-) diff --git a/docs/plugins/api-reference.mdx b/docs/plugins/api-reference.mdx index 6bb009b3..47f2020e 100644 --- a/docs/plugins/api-reference.mdx +++ b/docs/plugins/api-reference.mdx @@ -13,6 +13,7 @@ Complete reference for the `@ohcnetwork/leaderboard-api` package. interface Plugin { name: string; version: string; + configSchema?: ZodType>; setup?: (ctx: PluginContext) => Promise; scrape: (ctx: PluginContext) => Promise; aggregate?: (ctx: PluginContext) => Promise; @@ -35,6 +36,23 @@ interface Plugin { - **Description**: Semantic version of the plugin - **Example**: `'1.0.0'` +#### `configSchema` (optional) + +- **Type**: `ZodType>` +- **Description**: Zod schema used by the plugin runner to validate and parse `plugins..config` before calling `setup()`, `scrape()`, or `aggregate()`. + +**Example**: + +```typescript +import { z } from "zod"; + +const configSchema = z.object({ + apiToken: z.string().min(1), + repository: z.string().min(1), + limit: z.coerce.number().int().positive().default(100), +}); +``` + #### `badgeDefinitions` (optional) - **Type**: `BadgeDefinition[]` @@ -151,7 +169,7 @@ Database instance for storing data. ### `ctx.config` -Plugin-specific configuration from `config.yaml`. +Plugin-specific configuration from `config.yaml`, after parsing through `configSchema` if the plugin defines one. **Type**: `Record` diff --git a/docs/plugins/creating-plugins.mdx b/docs/plugins/creating-plugins.mdx index 4343982d..92b4b8bc 100644 --- a/docs/plugins/creating-plugins.mdx +++ b/docs/plugins/creating-plugins.mdx @@ -58,7 +58,7 @@ npm init -y Install the plugin API types: ```bash -npm install --save-dev @ohcnetwork/leaderboard-api +npm install @ohcnetwork/leaderboard-api zod ``` ## Step 2: Create the Plugin File @@ -77,10 +77,15 @@ import { activityQueries, contributorQueries, } from "@ohcnetwork/leaderboard-api"; +import { z } from "zod"; export default { name: "custom-plugin", version: "1.0.0", + configSchema: z.object({ + apiKey: z.string().min(1), + apiUrl: z.string().url().optional(), + }), /** * Setup method: Define activity types diff --git a/docs/plugins/overview.mdx b/docs/plugins/overview.mdx index 7900f95c..e248b889 100644 --- a/docs/plugins/overview.mdx +++ b/docs/plugins/overview.mdx @@ -346,6 +346,23 @@ async scrape(ctx) { } ``` +Plugins can also declare a `configSchema` on the manifest and let the plugin runner validate config before any lifecycle methods run: + +```typescript +import { z } from "zod"; + +export default { + name: "example-plugin", + version: "1.0.0", + configSchema: z.object({ + apiKey: z.string().min(1), + }), + async scrape(ctx) { + // ctx.config is already validated here + }, +}; +``` + ## Plugin Security ### Validation diff --git a/packages/api/package.json b/packages/api/package.json index aea077af..b7f18c2e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -38,7 +38,8 @@ "license": "MIT", "dependencies": { "@libsql/client": "^0.14.0", - "gray-matter": "^4.0.3" + "gray-matter": "^4.0.3", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.19.39", diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 5b12d47f..6697dfde 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -2,6 +2,8 @@ * Core types for the leaderboard plugin system */ +import type { ZodType } from "zod"; + /** * Database interface that plugins receive * Abstraction over LibSQL client @@ -77,7 +79,7 @@ export type PluginConfig = Record; /** * Context passed to plugin methods */ -export interface PluginContext { +export interface PluginContext { /** * Database instance for querying and writing data */ @@ -86,7 +88,7 @@ export interface PluginContext { /** * Plugin-specific configuration from config.yaml */ - config: PluginConfig; + config: TConfig; /** * Organization configuration @@ -102,7 +104,7 @@ export interface PluginContext { /** * Plugin interface that all plugins must implement */ -export interface Plugin { +export interface Plugin { /** * Unique name for the plugin */ @@ -113,16 +115,22 @@ export interface Plugin { */ version: string; + /** + * Optional Zod schema used to validate and parse the plugin-specific config + * from config.yaml before any lifecycle methods are called. + */ + configSchema?: ZodType; + /** * Optional setup method called before scraping * Used to populate activity_definition table and perform initialization */ - setup?: (ctx: PluginContext) => Promise; + setup?: (ctx: PluginContext) => Promise; /** * Main scrape method that fetches and stores activity data */ - scrape: (ctx: PluginContext) => Promise; + scrape: (ctx: PluginContext) => Promise; /** * Optional aggregate method called after all plugins have scraped @@ -130,7 +138,7 @@ export interface Plugin { * Used for computing plugin-specific aggregates that may depend * on the standard aggregates. */ - aggregate?: (ctx: PluginContext) => Promise; + aggregate?: (ctx: PluginContext) => Promise; /** * Optional badge definitions that this plugin provides. diff --git a/packages/create-plugin/src/templates/index-ts.ts b/packages/create-plugin/src/templates/index-ts.ts index 7fe8ec64..2494cc4e 100644 --- a/packages/create-plugin/src/templates/index-ts.ts +++ b/packages/create-plugin/src/templates/index-ts.ts @@ -20,12 +20,19 @@ import { type Plugin, type PluginContext, } from "@ohcnetwork/leaderboard-api"; +import { z } from "zod"; -const plugin: Plugin = { +const configSchema = z.object({ + // apiToken: z.string().min(1), +}); + +const plugin: Plugin> = { name: "${options.packageName}", version: "0.1.0", + // Optional: validate and parse plugin config before setup/scrape runs + configSchema, - async setup(ctx: PluginContext) { + async setup(ctx: PluginContext>) { ctx.logger.info("Setting up ${options.pluginName} plugin..."); // TODO: Define activity types here @@ -71,7 +78,7 @@ const plugin: Plugin = { ctx.logger.info("Setup complete"); }, - async scrape(ctx: PluginContext) { + async scrape(ctx: PluginContext>) { ctx.logger.info("Starting ${options.pluginName} data scraping..."); // TODO: Implement your scraping logic here diff --git a/packages/create-plugin/src/templates/package-json.ts b/packages/create-plugin/src/templates/package-json.ts index e5f97353..4738994d 100644 --- a/packages/create-plugin/src/templates/package-json.ts +++ b/packages/create-plugin/src/templates/package-json.ts @@ -21,6 +21,7 @@ export function generatePackageJson(options: PluginOptions): string { keywords: ["leaderboard", "plugin"], dependencies: { "@ohcnetwork/leaderboard-api": "^0.1.0", + zod: "^4.3.6", }, devDependencies: { "@types/node": "^20.19.27", diff --git a/packages/plugin-runner/src/__tests__/loader.test.ts b/packages/plugin-runner/src/__tests__/loader.test.ts index ca1a8d3e..701474bb 100644 --- a/packages/plugin-runner/src/__tests__/loader.test.ts +++ b/packages/plugin-runner/src/__tests__/loader.test.ts @@ -4,6 +4,7 @@ import type { Plugin } from "@ohcnetwork/leaderboard-api"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; // Re-create validatePlugin logic for testing since it's not exported function validatePlugin(plugin: unknown): asserts plugin is Plugin { @@ -21,6 +22,18 @@ function validatePlugin(plugin: unknown): asserts plugin is Plugin { throw new Error("Plugin must have a 'version' string property"); } + if ( + p.configSchema !== undefined && + (typeof p.configSchema !== "object" || + p.configSchema === null || + typeof (p.configSchema as { safeParse?: unknown }).safeParse !== + "function") + ) { + throw new Error( + "Plugin 'configSchema' must be a Zod schema with a 'safeParse' method if provided", + ); + } + if (typeof p.scrape !== "function") { throw new Error("Plugin must have a 'scrape' function"); } @@ -124,6 +137,19 @@ describe("Plugin Loader", () => { expect(() => validatePlugin(plugin)).not.toThrow(); }); + it("should validate plugin with configSchema", () => { + const plugin = { + name: "test-plugin", + version: "1.0.0", + configSchema: z.object({ + apiToken: z.string().min(1), + }), + scrape: async () => {}, + }; + + expect(() => validatePlugin(plugin)).not.toThrow(); + }); + it("should reject plugin without name", () => { const invalidPlugin = { version: "1.0.0", @@ -214,4 +240,17 @@ describe("Plugin Loader", () => { "Plugin 'badgeRules' must be an array if provided", ); }); + + it("should reject plugin with invalid configSchema", () => { + const invalidPlugin = { + name: "test", + version: "1.0.0", + scrape: async () => {}, + configSchema: { parse: () => ({}) }, + }; + + expect(() => validatePlugin(invalidPlugin)).toThrow( + "Plugin 'configSchema' must be a Zod schema with a 'safeParse' method if provided", + ); + }); }); diff --git a/packages/plugin-runner/src/__tests__/runner.test.ts b/packages/plugin-runner/src/__tests__/runner.test.ts index 187a2c94..944cbe00 100644 --- a/packages/plugin-runner/src/__tests__/runner.test.ts +++ b/packages/plugin-runner/src/__tests__/runner.test.ts @@ -2,6 +2,10 @@ * Plugin runner phase function tests */ +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { pathToFileURL } from "url"; import { badgeDefinitionQueries, createDatabase, @@ -60,6 +64,85 @@ describe("loadAllPlugins", () => { const result = await loadAllPlugins(config, logger); expect(result).toEqual([]); }); + + it("should validate plugin config with configSchema and return parsed config", async () => { + const dir = await mkdtemp(join(tmpdir(), "leaderboard-plugin-")); + + try { + const pluginPath = join(dir, "plugin.mjs"); + await writeFile( + pluginPath, + ` +import { z } from "zod"; + +export default { + name: "config-schema-plugin", + version: "1.0.0", + configSchema: z.object({ + apiToken: z.string().min(1), + limit: z.coerce.number().int().positive().default(10), + }), + async scrape() {}, +}; +`, + ); + + const config = makeConfig({ + test: { + source: pathToFileURL(pluginPath).href, + config: { + apiToken: "secret", + limit: "25", + }, + }, + }); + + const [loadedPlugin] = await loadAllPlugins(config, logger); + + expect(loadedPlugin.config).toEqual({ + apiToken: "secret", + limit: 25, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("should fail fast when plugin config does not match configSchema", async () => { + const dir = await mkdtemp(join(tmpdir(), "leaderboard-plugin-")); + + try { + const pluginPath = join(dir, "plugin.mjs"); + await writeFile( + pluginPath, + ` +import { z } from "zod"; + +export default { + name: "config-schema-plugin", + version: "1.0.0", + configSchema: z.object({ + apiToken: z.string().min(1), + }), + async scrape() {}, +}; +`, + ); + + const config = makeConfig({ + test: { + source: pathToFileURL(pluginPath).href, + config: {}, + }, + }); + + await expect(loadAllPlugins(config, logger)).rejects.toThrow( + "Configuration validation failed for plugin 'test'", + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); describe("setupPlugins", () => { diff --git a/packages/plugin-runner/src/loader.ts b/packages/plugin-runner/src/loader.ts index 7fda7bb7..fe146bdc 100644 --- a/packages/plugin-runner/src/loader.ts +++ b/packages/plugin-runner/src/loader.ts @@ -122,6 +122,18 @@ function validatePlugin(plugin: unknown): asserts plugin is Plugin { throw new Error("Plugin must have a 'version' string property"); } + if ( + p.configSchema !== undefined && + (typeof p.configSchema !== "object" || + p.configSchema === null || + typeof (p.configSchema as { safeParse?: unknown }).safeParse !== + "function") + ) { + throw new Error( + "Plugin 'configSchema' must be a Zod schema with a 'safeParse' method if provided", + ); + } + if (typeof p.scrape !== "function") { throw new Error("Plugin must have a 'scrape' function"); } diff --git a/packages/plugin-runner/src/runner.ts b/packages/plugin-runner/src/runner.ts index 9a54bc39..13f0a757 100644 --- a/packages/plugin-runner/src/runner.ts +++ b/packages/plugin-runner/src/runner.ts @@ -46,10 +46,15 @@ export async function loadAllPlugins( for (const [pluginId, pluginConfig] of pluginEntries) { try { const plugin = await loadPlugin(pluginConfig.source, logger); + const resolvedConfig = validatePluginConfig( + pluginId, + plugin, + (pluginConfig.config || {}) as Record, + ); loadedPlugins.push({ id: pluginId, plugin, - config: (pluginConfig.config || {}) as Record, + config: resolvedConfig, }); } catch (error) { logger.error(`Failed to load plugin ${pluginId}`, error as Error); @@ -60,6 +65,33 @@ export async function loadAllPlugins( return loadedPlugins; } +function validatePluginConfig( + pluginId: string, + plugin: Plugin, + config: Record, +): Record { + if (!plugin.configSchema) { + return config; + } + + const result = plugin.configSchema.safeParse(config); + + if (!result.success) { + const details = result.error.issues + .map((issue) => { + const path = issue.path.join(".") || "config"; + return ` - ${path}: ${issue.message}`; + }) + .join("\n"); + + throw new Error( + `Configuration validation failed for plugin '${pluginId}':\n${details}`, + ); + } + + return result.data as Record; +} + /** * Run setup phase for all plugins. * Also inserts badge definitions from config and plugin manifests. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d19f0076..2b899629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,9 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@types/node': specifier: ^20.19.39