#!/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(); }