scripts/check-alert-rule-shapes.mjs
Rama main · réplica de lectura del repositorio original.
#!/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();
}