Ctrl K
ComunidadGuías…

scripts/check-alert-rule-shapes.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Read-only audit of `alertRules` rows against the shape every writer now
 * enforces (shared/alert-rule-shape.ts): registry `variant`, bounded
 * `eventTypes` / `channels`, and at most ALERT_RULES_PER_USER_MAX rows per
 * account. Run it before deploying the bounds to see what the writers would
 * reject, and after deploying to confirm the cleanup landed.
 *
 * Why it exists (Strix pentest 2026-10-27): one account wrote 19 rows of
 * ~910 KB each and the relay's shared read of every enabled rule tripped the
 * Convex read limit for every tenant. The reads are paginated and byte-bounded
 * now, so this script can list those rows without tripping the same limit;
 * it never writes anything.
 *
 * Usage (deploy-key auth, same as the other operator scripts):
 *   CONVEX_URL=... CONVEX_DEPLOY_KEY=... node scripts/check-alert-rule-shapes.mjs
 *   node scripts/check-alert-rule-shapes.mjs --prod       # pass `--prod` through to `npx convex run`
 *   node scripts/check-alert-rule-shapes.mjs --json       # machine-readable report on stdout
 *
 * Exit codes: 0 clean, 1 violations found, 2 could not read the table.
 *
 * Cleanup is deliberately NOT automated here: delete or rewrite the listed
 * rows by hand (`npx convex run alertRules:setAlertRulesForUser` patches an
 * in-registry variant; out-of-registry rows can only be deleted from the
 * dashboard) so an operator reviews each account before touching it.
 */
import { collectConvexPages } from "./lib/convex-run-pages.mjs";

const PAGE_ITEMS = 64;

/**
 * Merges the pages of `alertRules:listShapeViolations`. The row cap comes
 * from the query itself (`rulesPerUserMax`) so this script never drifts from
 * shared/alert-rule-shape.ts.
 */
export function summarizeShapeAudit(pages) {
  const violations = pages.flatMap((p) => p.page);
  const rulesPerUserMax = pages[0]?.rulesPerUserMax ?? Infinity;
  const rowCounts = {};
  let scanned = 0;
  for (const p of pages) {
    scanned += p.scanned ?? 0;
    for (const [userId, count] of Object.entries(p.rowCounts ?? {})) {
      rowCounts[userId] = (rowCounts[userId] ?? 0) + count;
    }
  }
  const overCap = Object.entries(rowCounts)
    .filter(([, count]) => count > rulesPerUserMax)
    .map(([userId, count]) => ({ userId, rows: count }))
    .sort((a, b) => b.rows - a.rows);
  const bytesByUser = {};
  for (const v of violations) bytesByUser[v.userId] = (bytesByUser[v.userId] ?? 0) + v.approxBytes;
  return { scanned, rulesPerUserMax, violations, overCap, bytesByUser, clean: violations.length === 0 && overCap.length === 0 };
}

function formatBytes(n) {
  return n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(2)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`;
}

function main() {
  const argv = process.argv.slice(2);
  const asJson = argv.includes("--json");
  const extraArgs = argv.filter((a) => a !== "--json");
  if (!process.env.CONVEX_DEPLOY_KEY && !extraArgs.includes("--prod")) {
    console.error("[check-alert-rule-shapes] CONVEX_DEPLOY_KEY (or --prod with a logged-in convex CLI) is required for `npx convex run` on an internal query");
  }

  let pages;
  try {
    pages = collectConvexPages("alertRules:listShapeViolations", { numItems: PAGE_ITEMS }, {
      extraArgs,
      onPage: (result, index) => {
        if (!asJson) console.error(`[check-alert-rule-shapes] page ${index + 1}: scanned ${result.scanned} rows, ${result.page.length} out of shape`);
      },
    });
  } catch (err) {
    console.error(`[check-alert-rule-shapes] could not read alertRules: ${err.message}`);
    process.exit(2);
  }

  const report = summarizeShapeAudit(pages);
  if (asJson) {
    console.log(JSON.stringify(report, null, 2));
  } else {
    console.log(`[check-alert-rule-shapes] scanned ${report.scanned} alertRules rows`);
    if (report.clean) {
      console.log("[check-alert-rule-shapes] every row is in shape and no account exceeds the row cap");
    } else {
      console.log(`[check-alert-rule-shapes] ${report.violations.length} row(s) out of shape:`);
      for (const v of report.violations) {
        console.log(`  ${v.id}  user=${v.userId}  variant=${JSON.stringify(v.variant)}  enabled=${v.enabled}  ${formatBytes(v.approxBytes)}  ${v.codes.join(",")}`);
      }
      if (report.overCap.length > 0) {
        console.log(`[check-alert-rule-shapes] ${report.overCap.length} account(s) above the ${report.rulesPerUserMax}-row cap:`);
        for (const u of report.overCap) console.log(`  user=${u.userId}  rows=${u.rows}`);
      }
      const heavy = Object.entries(report.bytesByUser).sort((a, b) => b[1] - a[1]).slice(0, 10);
      if (heavy.length > 0) {
        console.log("[check-alert-rule-shapes] out-of-shape bytes by account:");
        for (const [userId, bytes] of heavy) console.log(`  user=${userId}  ${formatBytes(bytes)}`);
      }
    }
  }
  process.exit(report.clean ? 0 : 1);
}

if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
  main();
}