diff --git a/.changeset/fancy-peas-scream.md b/.changeset/fancy-peas-scream.md new file mode 100644 index 00000000..6874eb3b --- /dev/null +++ b/.changeset/fancy-peas-scream.md @@ -0,0 +1,8 @@ +--- +"create-leaderboard-plugin": minor +"@leaderboard/plugin-runner": minor +"@leaderboard/plugin-dummy": minor +"@ohcnetwork/leaderboard-api": minor +--- + +feat: add Zod schema validation for plugin configuration diff --git a/docs/plugins/api-reference.mdx b/docs/plugins/api-reference.mdx index 6bb009b3..deceb4f8 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?: ZodTypeAny; setup?: (ctx: PluginContext) => Promise; scrape: (ctx: PluginContext) => Promise; aggregate?: (ctx: PluginContext) => Promise; @@ -35,7 +36,35 @@ interface Plugin { - **Description**: Semantic version of the plugin - **Example**: `'1.0.0'` -#### `badgeDefinitions` (optional) +#### `configSchema` (optional) + +- **Type**: `ZodTypeAny` (from `zod`) +- **Description**: A Zod schema that validates the plugin's `config` block from `config.yaml`. When present, the plugin runner calls `.parse()` on the raw config before calling `setup()` or `scrape()`, so `ctx.config` is always type-safe. If validation fails, the run aborts with a descriptive error. +- **Example**: + +```typescript +import { z } from "zod"; +import type { Plugin } from "@ohcnetwork/leaderboard-api"; + +const configSchema = z.object({ + apiToken: z.string().min(1), + org: z.string().min(1), + pageSize: z.number().int().positive().default(100), +}); + +type Config = z.infer; + +const plugin: Plugin = { + name: "my-plugin", + version: "1.0.0", + configSchema, + async scrape(ctx) { + const config = ctx.config as Config; // already validated + const { apiToken, org, pageSize } = config; + // ... + }, +}; +``` - **Type**: `BadgeDefinition[]` - **Description**: Badge definitions that this plugin provides. Inserted into the database during the setup phase alongside config badge definitions. @@ -465,7 +494,7 @@ The plugin runner will catch and log the error, then exit with a non-zero code. 1. **Always use parameterized queries** to prevent SQL injection 2. **Use `INSERT OR IGNORE`** to prevent duplicate activities 3. **Log progress** to help with debugging -4. **Validate configuration** before making API calls +4. **Declare a `configSchema`** using Zod — the runner validates the config block before your plugin runs, giving users clear error messages instead of cryptic runtime failures 5. **Handle rate limits** appropriately 6. **Use batch inserts** for better performance 7. **Store unique slugs** to identify activities diff --git a/docs/plugins/creating-plugins.mdx b/docs/plugins/creating-plugins.mdx index 4343982d..0db7c9eb 100644 --- a/docs/plugins/creating-plugins.mdx +++ b/docs/plugins/creating-plugins.mdx @@ -206,11 +206,12 @@ export default { }; ``` -## Step 3: Add TypeScript Types (Optional) +## Step 3: Add TypeScript Types and Config Validation (Recommended) -For better developer experience, create `plugin.ts`: +For better developer experience and safer config handling, use Zod to validate your plugin's config. Create `plugin.ts`: ```typescript +import { z } from "zod"; import { activityDefinitionQueries, activityQueries, @@ -220,14 +221,28 @@ import { type PluginContext, } from "@ohcnetwork/leaderboard-api"; +/** + * Define and validate your plugin's config with Zod. + * The runner calls .parse() on the config.yaml block before setup() or scrape(), + * so ctx.config is always safe to use — no runtime surprises. + */ +const configSchema = z.object({ + apiKey: z.string().min(1, "apiKey is required"), + apiUrl: z.string().url().default("https://api.example.com/events"), +}); + +type Config = z.infer; + const plugin: Plugin = { name: "custom-plugin", version: "1.0.0", + // Attach the schema — the runner validates config before your code runs. + configSchema, + async setup(ctx: PluginContext): Promise { ctx.logger.info("Setting up custom plugin"); - // Define your activity types using the query helpers await activityDefinitionQueries.insertOrIgnore(ctx.db, { slug: "custom_event_1", name: "Event Type 1", @@ -240,14 +255,10 @@ const plugin: Plugin = { async scrape(ctx: PluginContext): Promise { ctx.logger.info("Starting scrape"); - // Get configuration with type safety - const { apiKey, apiUrl } = ctx.config as { - apiKey: string; - apiUrl?: string; - }; + // Config already validated — cast safely and destructure. + const { apiKey, apiUrl } = ctx.config as Config; - // Fetch and process data - const response = await fetch(apiUrl || "https://api.example.com/events", { + const response = await fetch(apiUrl, { headers: { Authorization: `Bearer ${apiKey}` }, }); @@ -263,7 +274,6 @@ const plugin: Plugin = { }>; for (const event of events) { - // Ensure contributor exists await contributorQueries.upsert(ctx.db, { username: event.user.username, name: event.user.name, @@ -276,7 +286,6 @@ const plugin: Plugin = { meta: null, }); - // Insert activity await activityQueries.upsert(ctx.db, { slug: `custom-${event.id}`, contributor: event.user.username, @@ -291,7 +300,6 @@ const plugin: Plugin = { } }, - // Optional: Compute aggregates after main leaderboard aggregation async aggregate(ctx: PluginContext): Promise { ctx.logger.info("Computing custom aggregates"); @@ -329,7 +337,45 @@ Build to JavaScript: tsc plugin.ts --module esnext --target es2022 ``` -> **Note:** If you used `pnpm create-leaderboard-plugin`, TypeScript is already configured and you can simply run `pnpm build`. +> **Note:** If you used `pnpm create-leaderboard-plugin`, TypeScript and Zod are already configured. Run `pnpm build`. + +## Validating Plugin Config with Zod + +Declaring a `configSchema` on your plugin is the recommended way to handle configuration. The plugin runner calls `schema.parse(rawConfig)` before invoking `setup()` or `scrape()`. If the config block in `config.yaml` is invalid, the run aborts immediately with a clear Zod error — before any API calls or database writes happen. + +### Example config.yaml + +```yaml +leaderboard: + plugins: + custom: + source: file:///path/to/plugin.js + config: + apiKey: ${{ env.MY_API_TOKEN }} + # apiUrl is optional — defaults to https://api.example.com/events +``` + +### What happens at runtime + +1. Runner loads the plugin from `source` +2. If `plugin.configSchema` exists, runner calls `configSchema.parse(config)` +3. On failure → run aborts with a detailed Zod validation error (field name + reason) +4. On success → `ctx.config` holds the parsed, type-safe config object +5. `setup()` and `scrape()` are called with the validated context + +### Minimal schema example + +```typescript +import { z } from "zod"; + +const configSchema = z.object({ + apiKey: z.string().min(1), // required + org: z.string().min(1), // required + pageSize: z.number().int().positive().default(100), // optional with default +}); +``` + +Plugins without a `configSchema` work exactly as before — no validation, no breaking change. ## Step 4: Test Locally 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..2b42e1de 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -6,6 +6,8 @@ * Database interface that plugins receive * Abstraction over LibSQL client */ +import type { ZodTypeAny } from "zod"; + export interface Database { /** * Execute a SQL statement @@ -113,6 +115,11 @@ export interface Plugin { */ version: string; + /** + * Optional Zod schema for validating the plugin's configuration + */ + configSchema?: ZodTypeAny; + /** * Optional setup method called before scraping * Used to populate activity_definition table and perform initialization diff --git a/packages/create-plugin/src/templates/index-ts.ts b/packages/create-plugin/src/templates/index-ts.ts index 7fe8ec64..be8c0f91 100644 --- a/packages/create-plugin/src/templates/index-ts.ts +++ b/packages/create-plugin/src/templates/index-ts.ts @@ -9,6 +9,7 @@ export function generateIndexTs(options: PluginOptions): string { * ${options.description} */ +import { z } from "zod"; import { activityDefinitionQueries, activityQueries, @@ -21,13 +22,43 @@ import { type PluginContext, } from "@ohcnetwork/leaderboard-api"; +/** + * Zod schema that validates and documents the config your plugin accepts. + * The runner validates config.yaml through this schema before calling + * setup() or scrape(), so ctx.config is always type-safe. + * + * Example config.yaml block: + * plugins: + * ${options.pluginName}: + * source: ./path/to/plugin.js + * config: + * apiToken: \${{ env.MY_API_TOKEN }} + * org: my-org + */ +const configSchema = z.object({ + // TODO: Replace with your plugin's actual config fields. + // apiToken: z.string().min(1), + // org: z.string().min(1), +}); + +type Config = z.infer; + const plugin: Plugin = { name: "${options.packageName}", version: "0.1.0", - + + /** + * Attach the schema so the runner validates config before startup. + * Remove this field if your plugin takes no configuration. + */ + configSchema, + async setup(ctx: PluginContext) { ctx.logger.info("Setting up ${options.pluginName} plugin..."); - + + // Config is already validated — cast it safely. + // const config = ctx.config as Config; + // TODO: Define activity types here // Example: // await activityDefinitionQueries.insertOrIgnore(ctx.db, { @@ -37,7 +68,7 @@ const plugin: Plugin = { // points: 10, // icon: "icon-name", // }); - + // TODO: Define contributor aggregate definitions (optional) // Example: // await contributorAggregateDefinitionQueries.upsert(ctx.db, { @@ -45,7 +76,7 @@ const plugin: Plugin = { // name: "Custom Metric", // description: "Example custom metric", // }); - + // TODO: Define badge definitions (optional) // Example: // await badgeDefinitionQueries.upsert(ctx.db, { @@ -53,46 +84,33 @@ const plugin: Plugin = { // name: "Example Badge", // description: "Achievement badge for custom criteria", // variants: { - // bronze: { - // description: "Level 1", - // svg_url: "https://example.com/bronze.svg", - // }, - // silver: { - // description: "Level 2", - // svg_url: "https://example.com/silver.svg", - // }, - // gold: { - // description: "Level 3", - // svg_url: "https://example.com/gold.svg", - // }, + // bronze: { description: "Level 1", svg_url: "https://example.com/bronze.svg" }, + // silver: { description: "Level 2", svg_url: "https://example.com/silver.svg" }, + // gold: { description: "Level 3", svg_url: "https://example.com/gold.svg" }, // }, // }); - + ctx.logger.info("Setup complete"); }, - + async scrape(ctx: PluginContext) { ctx.logger.info("Starting ${options.pluginName} data scraping..."); - + + // Config is already validated — cast it safely. + // const config = ctx.config as Config; + // TODO: Implement your scraping logic here // Example: - // const data = await fetchDataFromSource(ctx.config); - // + // const data = await fetchDataFromSource(config.apiToken); + // // for (const item of data) { - // // Ensure contributor exists // await contributorQueries.upsert(ctx.db, { // username: item.user.username, // name: item.user.name, - // role: null, - // title: null, - // avatar_url: item.user.avatar_url, - // bio: null, - // social_profiles: null, - // joining_date: null, - // meta: null, + // role: null, title: null, avatar_url: item.user.avatar_url, + // bio: null, social_profiles: null, joining_date: null, meta: null, // }); // - // // Insert activity // await activityQueries.upsert(ctx.db, { // slug: \`activity-\${item.id}\`, // contributor: item.user.username, @@ -101,25 +119,20 @@ const plugin: Plugin = { // occurred_at: new Date(item.timestamp).toISOString(), // link: item.url, // text: item.description, - // points: null, // Uses default from activity_definition + // points: null, // meta: null, // }); // } - + // TODO: Set custom aggregates (optional) // Example: // await contributorAggregateQueries.upsert(ctx.db, { // aggregate: "custom_metric", // contributor: "username", - // value: { - // type: "number", - // value: 42, - // unit: "items", - // format: "integer", - // }, + // value: { type: "number", value: 42, unit: "items", format: "integer" }, // meta: { source: "external_api" }, // }); - + // TODO: Award custom badges (optional) // Example: // await contributorBadgeQueries.award(ctx.db, { @@ -130,7 +143,7 @@ const plugin: Plugin = { // achieved_on: new Date().toISOString().split("T")[0], // meta: { reason: "Custom criteria met" }, // }); - + ctx.logger.info("Scraping complete"); }, }; diff --git a/packages/plugin-dummy/package.json b/packages/plugin-dummy/package.json index 16973a79..20888323 100644 --- a/packages/plugin-dummy/package.json +++ b/packages/plugin-dummy/package.json @@ -20,7 +20,8 @@ ], "dependencies": { "@faker-js/faker": "^9.9.0", - "@ohcnetwork/leaderboard-api": "workspace:*" + "@ohcnetwork/leaderboard-api": "workspace:*", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.19.39", diff --git a/packages/plugin-dummy/src/config.ts b/packages/plugin-dummy/src/config.ts index 4833333f..d086f68f 100644 --- a/packages/plugin-dummy/src/config.ts +++ b/packages/plugin-dummy/src/config.ts @@ -2,21 +2,34 @@ * Configuration types for dummy plugin */ -export interface DummyPluginConfig { - contributors?: { - count?: number; - minActivitiesPerContributor?: number; - maxActivitiesPerContributor?: number; - }; - activities?: { - daysBack?: number; - seed?: number; - }; - organization?: { - name?: string; - repoNames?: string[]; - }; -} +import { z } from "zod"; + +export const DummyPluginConfigSchema = z + .object({ + contributors: z + .object({ + count: z.number().int().positive().optional(), + minActivitiesPerContributor: z.number().int().nonnegative().optional(), + maxActivitiesPerContributor: z.number().int().positive().optional(), + }) + .optional(), + activities: z + .object({ + daysBack: z.number().int().positive().optional(), + seed: z.number().int().optional(), + }) + .optional(), + organization: z + .object({ + name: z.string().min(1).optional(), + repoNames: z.array(z.string().min(1)).optional(), + }) + .optional(), + }) + .optional() + .default({}); + +export type DummyPluginConfig = z.infer; export const DEFAULT_CONFIG = { contributors: { diff --git a/packages/plugin-dummy/src/index.ts b/packages/plugin-dummy/src/index.ts index 550c167e..ca91e509 100644 --- a/packages/plugin-dummy/src/index.ts +++ b/packages/plugin-dummy/src/index.ts @@ -16,12 +16,17 @@ import { contributorQueries, } from "@ohcnetwork/leaderboard-api"; import { ACTIVITY_TYPES, generateActivities } from "./activities"; -import { mergeConfig, type DummyPluginConfig } from "./config"; +import { + DummyPluginConfigSchema, + mergeConfig, + type DummyPluginConfig, +} from "./config"; import { generateContributors } from "./contributors"; const plugin: Plugin = { name: "@leaderboard/plugin-dummy", version: "0.1.0", + configSchema: DummyPluginConfigSchema, async setup(ctx: PluginContext) { ctx.logger.info("Setting up dummy plugin..."); diff --git a/packages/plugin-runner/src/__tests__/runner.test.ts b/packages/plugin-runner/src/__tests__/runner.test.ts index 187a2c94..429ab13f 100644 --- a/packages/plugin-runner/src/__tests__/runner.test.ts +++ b/packages/plugin-runner/src/__tests__/runner.test.ts @@ -1,15 +1,14 @@ -/** - * Plugin runner phase function tests - */ - import { badgeDefinitionQueries, createDatabase, initializeSchema, type Database, + type Plugin, } from "@ohcnetwork/leaderboard-api"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; import type { Config } from "../config"; +import * as loader from "../loader"; import { createLogger } from "../logger"; import { aggregatePlugins, @@ -62,6 +61,75 @@ describe("loadAllPlugins", () => { }); }); +describe("loadAllPlugins - configSchema validation", () => { + it("should pass valid config when plugin has a configSchema", async () => { + const mockPlugin: Plugin = { + name: "typed-plugin", + version: "1.0.0", + configSchema: z.object({ apiToken: z.string().min(1) }), + scrape: vi.fn(async () => {}), + }; + vi.spyOn(loader, "loadPlugin").mockResolvedValue(mockPlugin); + + const config = makeConfig({ + "typed-plugin": { + source: "file://fake", + config: { apiToken: "secret-token" }, + }, + } as any); + + const result = await loadAllPlugins(config, logger); + expect(result).toHaveLength(1); + expect(result[0].config).toEqual({ apiToken: "secret-token" }); + + vi.restoreAllMocks(); + }); + + it("should throw when plugin config fails schema validation", async () => { + const mockPlugin: Plugin = { + name: "typed-plugin", + version: "1.0.0", + configSchema: z.object({ apiToken: z.string().min(1) }), + scrape: vi.fn(async () => {}), + }; + vi.spyOn(loader, "loadPlugin").mockResolvedValue(mockPlugin); + + const config = makeConfig({ + "typed-plugin": { + source: "file://fake", + // apiToken is missing — should fail validation + config: {}, + }, + } as any); + + await expect(loadAllPlugins(config, logger)).rejects.toThrow(); + + vi.restoreAllMocks(); + }); + + it("should skip validation and load successfully when plugin has no configSchema", async () => { + const mockPlugin: Plugin = { + name: "untyped-plugin", + version: "1.0.0", + scrape: vi.fn(async () => {}), + }; + vi.spyOn(loader, "loadPlugin").mockResolvedValue(mockPlugin); + + const config = makeConfig({ + "untyped-plugin": { + source: "file://fake", + config: { anything: "goes" }, + }, + } as any); + + const result = await loadAllPlugins(config, logger); + expect(result).toHaveLength(1); + expect(result[0].config).toEqual({ anything: "goes" }); + + vi.restoreAllMocks(); + }); +}); + describe("setupPlugins", () => { let db: Database; diff --git a/packages/plugin-runner/src/runner.ts b/packages/plugin-runner/src/runner.ts index 9a54bc39..e4d438bc 100644 --- a/packages/plugin-runner/src/runner.ts +++ b/packages/plugin-runner/src/runner.ts @@ -46,10 +46,27 @@ export async function loadAllPlugins( for (const [pluginId, pluginConfig] of pluginEntries) { try { const plugin = await loadPlugin(pluginConfig.source, logger); + let parsedConfig = (pluginConfig.config || {}) as Record; + + if (plugin.configSchema) { + try { + parsedConfig = plugin.configSchema.parse(parsedConfig) as Record< + string, + unknown + >; + } catch (error) { + logger.error( + `Plugin config validation failed for ${pluginId}`, + error as Error, + ); + throw error; + } + } + loadedPlugins.push({ id: pluginId, plugin, - config: (pluginConfig.config || {}) as Record, + config: parsedConfig, }); } catch (error) { logger.error(`Failed to load plugin ${pluginId}`, error as Error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff8dfc50..721f6977 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,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 @@ -309,6 +312,9 @@ importers: '@ohcnetwork/leaderboard-api': specifier: workspace:* version: link:../api + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@types/node': specifier: ^20.19.39