From b7339e9afc2a6a7be3e4c111374648bfe6051a1f Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Tue, 26 May 2026 14:53:22 +0200 Subject: [PATCH 1/2] feat(config): add zod-validated YAML config loader Loads verifications.yaml and validates it with a strict zod schema: snowflake-format Discord IDs, valid cron expression, no duplicate verification names, no unknown fields. Wires the loader into the entry point so the bot fails fast on bad config. Also moves rootDir out of the base tsconfig (it was preventing test/**/* from typechecking) and into tsconfig.build.json where it actually matters. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.ts | 61 +++++++++++++++++++++++++++++++++++ src/index.ts | 15 ++++++--- test/config.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 3 ++ tsconfig.json | 1 - 5 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/config.ts create mode 100644 test/config.test.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..62115c5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,61 @@ +import { readFileSync } from "node:fs"; +import { validate as validateCron } from "node-cron"; +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; + +const snowflake = z.string().regex(/^\d{17,20}$/, { + message: "Must be a Discord snowflake ID (17-20 digits)", +}); + +const verification = z + .object({ + name: z.string().min(1), + guild_id: snowflake, + channel_id: snowflake, + message_id: snowflake, + emoji: z.string().trim().min(1), + role_id: snowflake, + on_remove: z.enum(["revoke", "keep"]).default("keep"), + }) + .strict(); + +const sweep = z + .object({ + on_startup: z.boolean(), + cron: z.string().refine((s) => validateCron(s), { + message: "Must be a valid cron expression", + }), + }) + .strict(); + +export const configSchema = z + .object({ + verifications: z.array(verification).min(1), + sweep, + }) + .strict() + .superRefine((cfg, ctx) => { + const seen = new Set(); + for (const [i, v] of cfg.verifications.entries()) { + if (seen.has(v.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["verifications", i, "name"], + message: `Duplicate verification name "${v.name}"`, + }); + } + seen.add(v.name); + } + }); + +export type Config = z.infer; +export type Verification = z.infer; + +export function parseConfig(input: string): Config { + const data = parseYaml(input); + return configSchema.parse(data); +} + +export function loadConfig(path: string): Config { + return parseConfig(readFileSync(path, "utf8")); +} diff --git a/src/index.ts b/src/index.ts index a17cf6d..b3af1ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,16 @@ -// yards-bot entry point. -// v1 implementation lands in follow-up PRs against this scaffold. -// See docs/exec-plans/v1.md for scope. +import { loadConfig } from "./config.js"; function main(): void { - console.log("yards-bot scaffold ready — v1 implementation pending"); + const path = process.env.CONFIG_PATH ?? "config/verifications.yaml"; + const cfg = loadConfig(path); + console.log(`Loaded config from ${path}`); + console.log(` ${cfg.verifications.length} verification(s):`); + for (const v of cfg.verifications) { + console.log( + ` - ${v.name}: guild=${v.guild_id} message=${v.message_id} emoji=${v.emoji} role=${v.role_id} on_remove=${v.on_remove}`, + ); + } + console.log(` sweep: on_startup=${cfg.sweep.on_startup} cron='${cfg.sweep.cron}'`); } main(); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..38014f4 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig } from "../src/config.js"; + +const validYaml = ` +verifications: + - name: rules + guild_id: "488772469411545098" + channel_id: "825007255195091025" + message_id: "1422180869384441906" + emoji: "✅" + role_id: "1422176009528152115" + on_remove: revoke +sweep: + on_startup: true + cron: "0 */6 * * *" +`; + +describe("parseConfig", () => { + it("parses a valid config", () => { + const cfg = parseConfig(validYaml); + expect(cfg.verifications).toHaveLength(1); + expect(cfg.verifications[0]?.name).toBe("rules"); + expect(cfg.verifications[0]?.on_remove).toBe("revoke"); + expect(cfg.sweep.cron).toBe("0 */6 * * *"); + }); + + it("defaults on_remove to 'keep' when omitted", () => { + const cfg = parseConfig(validYaml.replace(" on_remove: revoke\n", "")); + expect(cfg.verifications[0]?.on_remove).toBe("keep"); + }); + + it("rejects a non-snowflake guild_id", () => { + expect(() => parseConfig(validYaml.replace("488772469411545098", "abc"))).toThrow(/snowflake/); + }); + + it("rejects an invalid cron expression", () => { + expect(() => parseConfig(validYaml.replace("0 */6 * * *", "not a cron"))).toThrow(/cron/); + }); + + it("rejects an empty verifications array", () => { + const yaml = `verifications: []\nsweep:\n on_startup: true\n cron: "0 */6 * * *"\n`; + expect(() => parseConfig(yaml)).toThrow(); + }); + + it("rejects duplicate verification names", () => { + const yaml = ` +verifications: + - name: dup + guild_id: "488772469411545098" + channel_id: "825007255195091025" + message_id: "1422180869384441906" + emoji: "✅" + role_id: "1422176009528152115" + - name: dup + guild_id: "488772469411545098" + channel_id: "825007255195091025" + message_id: "1422180869384441907" + emoji: "❌" + role_id: "1422176009528152115" +sweep: + on_startup: true + cron: "0 */6 * * *" +`; + expect(() => parseConfig(yaml)).toThrow(/Duplicate/); + }); + + it("rejects unknown top-level fields", () => { + expect(() => parseConfig(`${validYaml}extra: hello\n`)).toThrow(); + }); + + it("rejects unknown verification fields", () => { + const yaml = validYaml.replace( + " on_remove: revoke", + " on_remove: revoke\n unknown_field: true", + ); + expect(() => parseConfig(yaml)).toThrow(); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index c45616c..7130e58 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,5 +1,8 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, "include": ["src/**/*"], "exclude": ["test/**/*", "**/*.test.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 66c80fc..90d2113 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,6 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "dist", - "rootDir": "src", "declaration": false, "sourceMap": true }, From 5b2aea8057c82e22bb1acf76d41b26f0302837c5 Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Tue, 26 May 2026 15:12:06 +0200 Subject: [PATCH 2/2] chore: replace real Discord IDs with synthetic placeholders in examples The initial scaffold's example config, tests, and exec-plan used the real Fleetyards guild/channel/message/role IDs. These aren't secrets, but examples in a public repo should be obviously synthetic so readers don't mistake them for required values. Note: the real IDs remain in this branch's earlier commits and on main. Not rewriting history since Discord snowflakes are public. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/verifications.example.yaml | 8 ++++---- docs/exec-plans/v1.md | 8 ++++---- test/config.test.ts | 26 +++++++++++++------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/config/verifications.example.yaml b/config/verifications.example.yaml index d69cda4..b0763a8 100644 --- a/config/verifications.example.yaml +++ b/config/verifications.example.yaml @@ -3,11 +3,11 @@ verifications: - name: rules-checkmark - guild_id: "488772469411545098" - channel_id: "825007255195091025" - message_id: "1422180869384441906" + guild_id: "1000000000000000001" + channel_id: "1000000000000000002" + message_id: "1000000000000000003" emoji: "✅" # unicode emoji, or "name:id" for a custom server emoji - role_id: "1422176009528152115" + role_id: "1000000000000000004" on_remove: revoke # revoke | keep sweep: diff --git a/docs/exec-plans/v1.md b/docs/exec-plans/v1.md index e49b770..2a2001b 100644 --- a/docs/exec-plans/v1.md +++ b/docs/exec-plans/v1.md @@ -59,11 +59,11 @@ YAML, validated with zod at startup. Bot exits non-zero on invalid config. ```yaml verifications: - name: rules-checkmark - guild_id: "488772469411545098" - channel_id: "825007255195091025" - message_id: "1422180869384441906" + guild_id: "1000000000000000001" + channel_id: "1000000000000000002" + message_id: "1000000000000000003" emoji: "✅" # unicode emoji, or "name:id" for custom - role_id: "1422176009528152115" + role_id: "1000000000000000004" on_remove: revoke # revoke | keep sweep: diff --git a/test/config.test.ts b/test/config.test.ts index 38014f4..2151f84 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -4,11 +4,11 @@ import { parseConfig } from "../src/config.js"; const validYaml = ` verifications: - name: rules - guild_id: "488772469411545098" - channel_id: "825007255195091025" - message_id: "1422180869384441906" + guild_id: "1000000000000000001" + channel_id: "1000000000000000002" + message_id: "1000000000000000003" emoji: "✅" - role_id: "1422176009528152115" + role_id: "1000000000000000004" on_remove: revoke sweep: on_startup: true @@ -30,7 +30,7 @@ describe("parseConfig", () => { }); it("rejects a non-snowflake guild_id", () => { - expect(() => parseConfig(validYaml.replace("488772469411545098", "abc"))).toThrow(/snowflake/); + expect(() => parseConfig(validYaml.replace("1000000000000000001", "abc"))).toThrow(/snowflake/); }); it("rejects an invalid cron expression", () => { @@ -46,17 +46,17 @@ describe("parseConfig", () => { const yaml = ` verifications: - name: dup - guild_id: "488772469411545098" - channel_id: "825007255195091025" - message_id: "1422180869384441906" + guild_id: "1000000000000000001" + channel_id: "1000000000000000002" + message_id: "1000000000000000003" emoji: "✅" - role_id: "1422176009528152115" + role_id: "1000000000000000004" - name: dup - guild_id: "488772469411545098" - channel_id: "825007255195091025" - message_id: "1422180869384441907" + guild_id: "1000000000000000001" + channel_id: "1000000000000000002" + message_id: "1000000000000000005" emoji: "❌" - role_id: "1422176009528152115" + role_id: "1000000000000000004" sweep: on_startup: true cron: "0 */6 * * *"