Files
diegosouzapw--omniroute/scripts/check/check-openapi-security-tiers.mjs
2026-07-13 13:39:12 +08:00

121 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Cross-references openapi.yaml x-loopback-only / x-always-protected annotations
* against the compile-time constants in src/server/authz/routeGuard.ts.
*
* Fails if any YAML annotation disagrees with the routeGuard.ts constants.
*/
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
const ROOT = process.cwd();
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts");
function parseStringArray(match) {
if (!match) return [];
// Strip line comments before splitting — array entries in routeGuard.ts often
// carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
return match[1]
.replace(/\/\/[^\n]*/g, "")
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}
const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
const LOCAL_ONLY_PREFIXES = parseStringArray(
guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s)
);
const ALWAYS_PROTECTED_PATHS = parseStringArray(
guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s)
);
if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) {
console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants");
process.exit(1);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};
const errors = [];
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
for (const [method, spec] of Object.entries(methods)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] === true) {
const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!matchesPrefix) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]`
);
}
}
if (spec["x-always-protected"] === true) {
const matchesPath = ALWAYS_PROTECTED_PATHS.some(
(p) => pathStr === p || pathStr.startsWith(`${p}/`)
);
if (!matchesPath) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
`ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
);
}
}
}
}
// Reverse pass: every YAML path that falls under a LOCAL_ONLY prefix should
// carry `x-loopback-only: true` on every method, otherwise external API
// consumers have no signal that the route is loopback-restricted. Closes the
// "new spawn-capable route added without annotation" regression class.
//
// Currently reported as warnings (non-fatal) because the v3.8.4 release ships
// with a known annotation gap on /api/services/* and /api/cli-tools/runtime/*
// that will be patched in a follow-up doc-only PR. Promote to errors once the
// backlog is cleared.
const reverseWarnings = [];
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!fallsUnderLocalOnly) continue;
for (const [method, spec] of Object.entries(methods)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] !== true) {
reverseWarnings.push(
`${method.toUpperCase()} ${pathStr}: falls under LOCAL_ONLY_API_PREFIXES ` +
`but is missing x-loopback-only: true annotation`
);
}
}
}
if (reverseWarnings.length > 0) {
console.warn(
`[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal, follow-up doc PR):`
);
reverseWarnings.forEach((w) => console.warn(` - ${w}`));
}
if (errors.length === 0) {
console.log("[openapi-security-tiers] PASS — all security tier annotations match routeGuard.ts");
process.exit(0);
} else {
console.error(`[openapi-security-tiers] FAIL — ${errors.length} annotation mismatches:`);
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}