Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-api-auth-surface.mjs
T

175 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable no-console */
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultRepoRoot = path.resolve(__dirname, "..");
const centralResolverFile = "apps/web/app/utils/services/apiService.ts";
const apiSurfaceRoots = [
{ path: "apps/api/src", required: true },
{ path: "apps/web/app/routes/internal/api", required: false },
];
const knownFlags = new Set(["--json"]);
const callsiteSymbols = [
["validateApiKey", "directValidateApiKeyCallsites"],
["validateTenantApiKey", "tenantValidateCallsites"],
["getEntityApiKeyFromRequest", "entityResolverCallsites"],
["getRelationshipApiKeyFromRequest", "relationshipHelperCallsites"],
["getWorkflowApiKeyFromRequest", "workflowHelperCallsites"],
];
function usage() {
return "Usage: node scripts/verify-api-auth-surface.mjs [--json]";
}
function toRelative(rootDir, filePath) {
return path.relative(rootDir, filePath).split(path.sep).join("/");
}
function sortEntries(entries) {
return entries.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.symbol.localeCompare(b.symbol));
}
function makeEntry(file, line, lineText, symbol) {
return {
file,
line,
symbol,
text: lineText.trim(),
};
}
async function collectRouteFiles(rootDir, dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await collectRouteFiles(rootDir, fullPath));
} else if (entry.isFile() && /\.tsx?$/.test(entry.name)) {
files.push(fullPath);
}
}
return files.sort((a, b) => toRelative(rootDir, a).localeCompare(toRelative(rootDir, b)));
}
export async function buildInventory(rootDir = defaultRepoRoot) {
const missingRoots = [];
const requiredMissingRoots = [];
const routeFiles = (
await Promise.all(
apiSurfaceRoots.map(async (root) => {
try {
return await collectRouteFiles(rootDir, path.join(rootDir, root.path));
} catch (error) {
if (error?.code === "ENOENT") {
missingRoots.push(root.path);
if (root.required) requiredMissingRoots.push(root.path);
return [];
}
throw error;
}
})
)
).flat().sort((a, b) => toRelative(rootDir, a).localeCompare(toRelative(rootDir, b)));
const inventory = {
scannedFiles: routeFiles.map((filePath) => toRelative(rootDir, filePath)),
scannedRoots: apiSurfaceRoots.map((root) => root.path).filter((root) => !missingRoots.includes(root)),
missingRoots: missingRoots.sort(),
requiredMissingRoots: requiredMissingRoots.sort(),
directValidateApiKeyCallsites: [],
tenantValidateCallsites: [],
entityResolverCallsites: [],
relationshipHelperCallsites: [],
workflowHelperCallsites: [],
apiServiceImports: [],
centralResolverFile,
};
for (const filePath of routeFiles) {
const file = toRelative(rootDir, filePath);
const source = await readFile(filePath, "utf8");
source.split(/\r?\n/).forEach((lineText, index) => {
const line = index + 1;
if (lineText.includes("~/utils/services/apiService")) {
inventory.apiServiceImports.push(makeEntry(file, line, lineText, "apiService import"));
}
for (const [symbol, field] of callsiteSymbols) {
const pattern = new RegExp(`\\b${symbol}\\s*\\(`);
if (pattern.test(lineText)) {
inventory[field].push(makeEntry(file, line, lineText, symbol));
}
}
});
}
for (const [, field] of callsiteSymbols) {
sortEntries(inventory[field]);
}
sortEntries(inventory.apiServiceImports);
return inventory;
}
export function hasInventoryIntegrityFailure(inventory) {
return inventory.requiredMissingRoots.length > 0 || inventory.scannedFiles.length === 0;
}
function renderSection(title, entries) {
const rows = entries.length
? entries.map((entry) => ` ${entry.file}:${entry.line} ${entry.symbol} ${entry.text}`)
: [" (none)"];
return [title, ...rows].join("\n");
}
export function renderText(inventory) {
return [
"API Auth Surface API auth surface inventory",
`Scanned files: ${inventory.scannedFiles.length}`,
`Scanned roots: ${inventory.scannedRoots.join(", ") || "(none)"}`,
`Missing roots: ${inventory.missingRoots.join(", ") || "(none)"}`,
`Central resolver file: ${inventory.centralResolverFile}`,
renderSection("\nvalidateApiKey callsites", inventory.directValidateApiKeyCallsites),
renderSection("\nvalidateTenantApiKey callsites", inventory.tenantValidateCallsites),
renderSection("\ngetEntityApiKeyFromRequest callsites", inventory.entityResolverCallsites),
renderSection("\ngetRelationshipApiKeyFromRequest callsites", inventory.relationshipHelperCallsites),
renderSection("\ngetWorkflowApiKeyFromRequest callsites", inventory.workflowHelperCallsites),
renderSection("\napiService imports", inventory.apiServiceImports),
].join("\n");
}
async function main() {
const args = process.argv.slice(2);
const unknownFlag = args.find((arg) => arg.startsWith("-") && !knownFlags.has(arg));
if (unknownFlag) {
console.error(`Unknown flag: ${unknownFlag}`);
console.error(usage());
process.exit(2);
}
try {
const inventory = await buildInventory(defaultRepoRoot);
if (hasInventoryIntegrityFailure(inventory)) {
console.error(renderText(inventory));
console.error("API auth surface inventory integrity check failed.");
process.exit(2);
}
if (args.includes("--json")) {
console.log(JSON.stringify(inventory, null, 2));
} else {
console.log(renderText(inventory));
}
} catch (error) {
if (error?.code === "ENOENT") {
console.error("Missing API routes directory: apps/web/app/routes/internal/api");
process.exit(2);
}
throw error;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}