Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/plugins/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Complete reference for the `@ohcnetwork/leaderboard-api` package.
interface Plugin {
name: string;
version: string;
configSchema?: ZodType<Record<string, unknown>>;
setup?: (ctx: PluginContext) => Promise<void>;
scrape: (ctx: PluginContext) => Promise<void>;
aggregate?: (ctx: PluginContext) => Promise<void>;
Expand All @@ -35,6 +36,23 @@ interface Plugin {
- **Description**: Semantic version of the plugin
- **Example**: `'1.0.0'`

#### `configSchema` (optional)

- **Type**: `ZodType<Record<string, unknown>>`
- **Description**: Zod schema used by the plugin runner to validate and parse `plugins.<id>.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[]`
Expand Down Expand Up @@ -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<string, unknown>`

Expand Down
7 changes: 6 additions & 1 deletion docs/plugins/creating-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/plugins/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 14 additions & 6 deletions packages/api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,7 +79,7 @@ export type PluginConfig = Record<string, unknown>;
/**
* Context passed to plugin methods
*/
export interface PluginContext {
export interface PluginContext<TConfig extends PluginConfig = PluginConfig> {
/**
* Database instance for querying and writing data
*/
Expand All @@ -86,7 +88,7 @@ export interface PluginContext {
/**
* Plugin-specific configuration from config.yaml
*/
config: PluginConfig;
config: TConfig;

/**
* Organization configuration
Expand All @@ -102,7 +104,7 @@ export interface PluginContext {
/**
* Plugin interface that all plugins must implement
*/
export interface Plugin {
export interface Plugin<TConfig extends PluginConfig = PluginConfig> {
/**
* Unique name for the plugin
*/
Expand All @@ -113,24 +115,30 @@ 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<TConfig>;

/**
* Optional setup method called before scraping
* Used to populate activity_definition table and perform initialization
*/
setup?: (ctx: PluginContext) => Promise<void>;
setup?: (ctx: PluginContext<TConfig>) => Promise<void>;

/**
* Main scrape method that fetches and stores activity data
*/
scrape: (ctx: PluginContext) => Promise<void>;
scrape: (ctx: PluginContext<TConfig>) => Promise<void>;

/**
* Optional aggregate method called after all plugins have scraped
* and the main leaderboard aggregation has completed.
* Used for computing plugin-specific aggregates that may depend
* on the standard aggregates.
*/
aggregate?: (ctx: PluginContext) => Promise<void>;
aggregate?: (ctx: PluginContext<TConfig>) => Promise<void>;

/**
* Optional badge definitions that this plugin provides.
Expand Down
13 changes: 10 additions & 3 deletions packages/create-plugin/src/templates/index-ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<z.infer<typeof configSchema>> = {
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<z.infer<typeof configSchema>>) {
ctx.logger.info("Setting up ${options.pluginName} plugin...");

// TODO: Define activity types here
Expand Down Expand Up @@ -71,7 +78,7 @@ const plugin: Plugin = {
ctx.logger.info("Setup complete");
},

async scrape(ctx: PluginContext) {
async scrape(ctx: PluginContext<z.infer<typeof configSchema>>) {
ctx.logger.info("Starting ${options.pluginName} data scraping...");

// TODO: Implement your scraping logic here
Expand Down
1 change: 1 addition & 0 deletions packages/create-plugin/src/templates/package-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions packages/plugin-runner/src/__tests__/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
);
});
});
83 changes: 83 additions & 0 deletions packages/plugin-runner/src/__tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/plugin-runner/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
Loading