175 lines
6.0 KiB
JavaScript
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();
|
|
}
|