From 769fa24358074d7e9d4e720dc2129e1c63d637d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:31:50 +0000 Subject: [PATCH] ci: add webhooks guide consistency guard Adds scripts/check-webhooks-guide.mjs, which asserts guides/webhooks.mdx still matches the canonical webhook contract (event catalog, delivery headers, whd_ delivery id, envelope fields, retry backoff, verifyWebhook usage, SDK methods, endpoint limit). Wired into the validate workflow and package.json alongside the existing frontmatter/nav/plan-limit guards so the guide can't silently drift. --- .github/workflows/validate-frontmatter.yml | 4 + package.json | 3 +- scripts/check-webhooks-guide.mjs | 104 +++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 scripts/check-webhooks-guide.mjs diff --git a/.github/workflows/validate-frontmatter.yml b/.github/workflows/validate-frontmatter.yml index 9b21941..bb4a58d 100644 --- a/.github/workflows/validate-frontmatter.yml +++ b/.github/workflows/validate-frontmatter.yml @@ -13,6 +13,7 @@ on: - "scripts/validate-frontmatter.mjs" - "scripts/validate-nav-and-redirects.mjs" - "scripts/check-plan-limits.mjs" + - "scripts/check-webhooks-guide.mjs" - "package.json" pull_request: paths: @@ -22,6 +23,7 @@ on: - "scripts/validate-frontmatter.mjs" - "scripts/validate-nav-and-redirects.mjs" - "scripts/check-plan-limits.mjs" + - "scripts/check-webhooks-guide.mjs" - "package.json" jobs: @@ -39,3 +41,5 @@ jobs: run: node scripts/validate-nav-and-redirects.mjs - name: Check plan-limit tables match the source of truth run: node scripts/check-plan-limits.mjs + - name: Check webhooks guide matches the webhook contract + run: node scripts/check-webhooks-guide.mjs diff --git a/package.json b/package.json index 8d8af0e..7be33f8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "scripts": { "validate:frontmatter": "node scripts/validate-frontmatter.mjs", "validate:nav": "node scripts/validate-nav-and-redirects.mjs", - "validate:plan-limits": "node scripts/check-plan-limits.mjs" + "validate:plan-limits": "node scripts/check-plan-limits.mjs", + "validate:webhooks": "node scripts/check-webhooks-guide.mjs" } } diff --git a/scripts/check-webhooks-guide.mjs b/scripts/check-webhooks-guide.mjs new file mode 100644 index 0000000..502e49a --- /dev/null +++ b/scripts/check-webhooks-guide.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Webhooks guide consistency guard. + * + * Asserts the facts in guides/webhooks.mdx still match the canonical webhook + * contract defined in the monorepo. Source of truth: + * - packages/shared/src/constants/webhook-events.ts (event catalog, retry + * backoff, MAX_ENDPOINTS_PER_ORG) + * - packages/shared/src/types/webhook.ts (envelope fields) + * - apps/api/src/routes/webhook-endpoints.ts (whd_ delivery id, + * header names) + lib/webhooks.ts (signed string {timestamp}.{body}) + * - packages/sdk/src/lib/webhook-verify.ts (verifyWebhook helper) + * + * The docs repo does not vendor the monorepo, so the contract is embedded below. + * When the contract changes in the monorepo, update EXPECTED here and the guide + * in the same change. Exit 0 = consistent, exit 1 = drift. + */ + +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); +const FILE = "guides/webhooks.mdx"; + +// ── Canonical contract (mirror of the monorepo; see header) ────────────────── +const EVENTS = [ + "job.created", "job.started", "job.completed", + "job.failed", "job.cancelled", "balance.low", "balance.depleted", +]; +const HEADERS = [ + "X-Rendobar-Signature", "X-Rendobar-Timestamp", + "X-Rendobar-Event", "X-Rendobar-Delivery", "X-Rendobar-Attempt", +]; +const ENVELOPE_FIELDS = ["version", "event", "deliveryId", "timestamp", "orgId", "data"]; +const RETRY_DELAYS_SEC = [10, 20, 40, 80, 160]; +const SDK_METHODS = ["rotateSecret", "retryDelivery"]; // methods the guide shows in code +const MAX_ENDPOINTS = 10; +const DELIVERY_ID_PREFIX = "whd_"; +const STALE_DELIVERY_ID_PREFIX = "del_"; // the bug we fixed; it must never return +const SIGNED_STRING = "{timestamp}.{body}"; +const SDK_WEBHOOKS_ENTRY = "@rendobar/sdk/webhooks"; +const EM_DASH = "—"; +const EN_DASH = "–"; + +const md = readFileSync(join(ROOT, FILE), "utf8"); +// Prose only: strip fenced blocks and inline code so code samples are not +// flagged by the punctuation check. +const prose = md.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, ""); + +const checks = []; +const check = (name, ok, detail = "") => checks.push({ name, ok, detail }); + +// 1. Every catalog event is documented +for (const e of EVENTS) check(`event ${e} documented`, md.includes(`\`${e}\``)); + +// 2. No event outside the catalog is documented (catches renamed/removed events) +const mentioned = [...md.matchAll(/`((?:job|balance)\.[a-z.]+)`/g)].map((m) => m[1]); +const unknown = [...new Set(mentioned)].filter((e) => !EVENTS.includes(e)); +check("no unknown events documented", unknown.length === 0, unknown.join(", ")); + +// 3. All delivery headers present +for (const h of HEADERS) check(`header ${h}`, md.includes(h)); + +// 4. Delivery id uses whd_, and the old del_ is gone +check(`delivery id prefix ${DELIVERY_ID_PREFIX}`, md.includes(DELIVERY_ID_PREFIX)); +check(`stale ${STALE_DELIVERY_ID_PREFIX} id removed`, !md.includes(STALE_DELIVERY_ID_PREFIX)); + +// 5. Envelope fields documented +for (const f of ENVELOPE_FIELDS) check(`envelope field ${f}`, md.includes(`\`${f}\``)); + +// 6. Retry backoff numbers present +for (const d of RETRY_DELAYS_SEC) check(`retry delay ${d}s`, new RegExp(`\\b${d} s\\b`).test(md)); + +// 7. Verification leads with the SDK one-liner +check(`imports ${SDK_WEBHOOKS_ENTRY}`, md.includes(SDK_WEBHOOKS_ENTRY)); +check("uses verifyWebhook()", md.includes("verifyWebhook(")); +check("documents signed string", md.includes(SIGNED_STRING)); + +// 8. SDK management methods referenced + the dashboard test action +for (const m of SDK_METHODS) check(`sdk client.webhooks.${m}()`, md.includes(`${m}(`)); +check("mentions the Send Test action", /send test/i.test(md)); + +// 9. Limits + dashboard link +check(`max ${MAX_ENDPOINTS} endpoints noted`, new RegExp(`up to ${MAX_ENDPOINTS}\\b`).test(md)); +check("links to the webhooks dashboard", md.includes("app.rendobar.com/webhooks")); + +// 10. Prose punctuation (writing-style rule) +check("no em-dash in prose", !prose.includes(EM_DASH)); +check("no en-dash in prose", !prose.includes(EN_DASH)); + +// ── Report ─────────────────────────────────────────────────────────────── +const failed = checks.filter((c) => !c.ok); +for (const c of checks) { + console.log(`${c.ok ? "✓" : "✗"} ${c.name}${!c.ok && c.detail ? ` (${c.detail})` : ""}`); +} +console.log(`\n${checks.length - failed.length}/${checks.length} checks passed.`); +if (failed.length) { + console.error(`✗ webhooks guide drifted from the contract. Fix ${FILE}, or update EXPECTED in this script if the contract itself changed.`); + process.exitCode = 1; +} else { + console.log("✓ webhooks guide matches the webhook contract."); +}