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
8 changes: 8 additions & 0 deletions .changeset/fancy-peas-scream.md
Original file line number Diff line number Diff line change
@@ -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
33 changes: 31 additions & 2 deletions 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?: ZodTypeAny;
setup?: (ctx: PluginContext) => Promise<void>;
scrape: (ctx: PluginContext) => Promise<void>;
aggregate?: (ctx: PluginContext) => Promise<void>;
Expand All @@ -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<typeof configSchema>;

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.
Expand Down Expand Up @@ -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
Expand Down
74 changes: 60 additions & 14 deletions docs/plugins/creating-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof configSchema>;

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<void> {
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",
Expand All @@ -240,14 +255,10 @@ const plugin: Plugin = {
async scrape(ctx: PluginContext): Promise<void> {
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}` },
});

Expand All @@ -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,
Expand All @@ -276,7 +286,6 @@ const plugin: Plugin = {
meta: null,
});

// Insert activity
await activityQueries.upsert(ctx.db, {
slug: `custom-${event.id}`,
contributor: event.user.username,
Expand All @@ -291,7 +300,6 @@ const plugin: Plugin = {
}
},

// Optional: Compute aggregates after main leaderboard aggregation
async aggregate(ctx: PluginContext): Promise<void> {
ctx.logger.info("Computing custom aggregates");

Expand Down Expand Up @@ -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

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
7 changes: 7 additions & 0 deletions packages/api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
93 changes: 53 additions & 40 deletions packages/create-plugin/src/templates/index-ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function generateIndexTs(options: PluginOptions): string {
* ${options.description}
*/

import { z } from "zod";
import {
activityDefinitionQueries,
activityQueries,
Expand All @@ -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<typeof configSchema>;

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, {
Expand All @@ -37,62 +68,49 @@ const plugin: Plugin = {
// points: 10,
// icon: "icon-name",
// });

// TODO: Define contributor aggregate definitions (optional)
// Example:
// await contributorAggregateDefinitionQueries.upsert(ctx.db, {
// slug: "custom_metric",
// name: "Custom Metric",
// description: "Example custom metric",
// });

// TODO: Define badge definitions (optional)
// Example:
// await badgeDefinitionQueries.upsert(ctx.db, {
// slug: "example_badge",
// 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,
Expand All @@ -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, {
Expand All @@ -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");
},
};
Expand Down
Loading