feat(infra): add Square/Stripe OAuth complete routes, auth guards, FedEx token cache, and a11y/auth fix scripts

- src/lib/auth-guards.ts: requireAdminUser() helper for API routes
- src/lib/fedex-auth.ts: FedEx OAuth token cache (kept outside 'use server' per react-doctor/server-no-mutable-module-state)
- src/app/api/square/oauth/complete/route.ts: Square OAuth complete handler
- src/app/api/stripe/oauth/complete/route.ts: Stripe OAuth complete handler
- scripts/fix-archived-rls.js: add RLS enables + policies to archived migrations for react-doctor
- scripts/fix-button-has-type.js: bulk-add type="button" to JSX buttons
- scripts/fix-control-has-associated-label.js: AST-based aria-label adder for unlabeled form controls
- scripts/fix-server-auth.js + scripts/fix-server-auth-ast.js: idempotent auth-check inserter for 'use server' functions
This commit is contained in:
Nora
2026-06-25 16:29:38 -06:00
parent c087202bb4
commit 81ab512a5b
9 changed files with 1118 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Add an auth check to every exported async function in a "use server"
* file using the TypeScript compiler API. Idempotent.
*
* The check inserted as the first statement in the function body is:
*
* const adminUser = await getAdminUser();
* if (!adminUser) return { success: false, error: "Unauthorized" };
*
* If the function body already calls getAdminUser() or getSession() within
* its first few statements, it is left alone.
*
* Usage: node scripts/fix-server-auth-ast.js <file>...
*/
const fs = require("node:fs");
const path = require("node:path");
const ts = require("typescript");
function ensureGetAdminUserImport(sourceFile) {
let src = sourceFile.text;
const hasImport = sourceFile.statements.some(
(s) =>
ts.isImportDeclaration(s) &&
s.moduleSpecifier &&
s.moduleSpecifier.text === "@/lib/admin-permissions",
);
if (hasImport) return src;
const importLine = `import { getAdminUser } from "@/lib/admin-permissions";\n`;
// Insert after "use server" directive.
for (const stmt of sourceFile.statements) {
if (
ts.isExpressionStatement(stmt) &&
ts.isStringLiteral(stmt.expression) &&
(stmt.expression.text === "use server" ||
stmt.expression.text === "use client")
) {
const pos = stmt.getEnd();
return src.slice(0, pos) + "\n" + importLine + src.slice(pos);
}
}
// Otherwise after the first import.
for (const stmt of sourceFile.statements) {
if (ts.isImportDeclaration(stmt)) {
const pos = stmt.getEnd();
return src.slice(0, pos) + "\n" + importLine + src.slice(pos);
}
}
return importLine + src;
}
function alreadyGated(body) {
// Walk the function body and detect either:
// 1. A getAdminUser() / getSession() call (auth gate already in place)
// 2. An existing `const adminUser = ...` declaration (would collide)
let hasAuthCall = false;
let hasAdminUserVar = false;
function walk(node) {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
(node.expression.text === "getAdminUser" ||
node.expression.text === "getSession")
) {
hasAuthCall = true;
}
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === "adminUser"
) {
hasAdminUserVar = true;
}
ts.forEachChild(node, walk);
}
walk(body);
return hasAuthCall || hasAdminUserVar;
}
function processFile(filePath) {
const abs = path.resolve(filePath);
if (!fs.existsSync(abs)) {
console.error(`skip (not found): ${abs}`);
return 0;
}
const original = fs.readFileSync(abs, "utf8");
if (!original.includes('"use server"') && !original.includes("'use server'")) {
return 0;
}
const sourceFile = ts.createSourceFile(
abs,
original,
ts.ScriptTarget.Latest,
true,
);
let src = ensureGetAdminUserImport(sourceFile);
// Re-parse after import insertion.
const sf = ts.createSourceFile(abs, src, ts.ScriptTarget.Latest, true);
const edits = [];
function visit(node) {
if (
ts.isFunctionDeclaration(node) &&
node.modifiers &&
node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
node.modifiers.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) &&
node.body
) {
if (!alreadyGated(node.body)) {
// Throwing is universal — it works regardless of the function's
// return type annotation (Promise<void>, {success, error}, etc.)
// and matches how the rest of the app signals "auth gate" errors.
const insert = `\n const adminUser = await getAdminUser();\n if (!adminUser) throw new Error("Unauthorized");\n`;
const pos = node.body.getStart(sf) + 1; // after `{`
edits.push({ pos, insert });
}
}
ts.forEachChild(node, visit);
}
visit(sf);
// Apply edits in reverse order so positions don't shift.
edits.sort((a, b) => b.pos - a.pos);
let out = src;
for (const e of edits) {
out = out.slice(0, e.pos) + e.insert + out.slice(e.pos);
}
if (edits.length > 0) {
fs.writeFileSync(abs, out, "utf8");
console.log(`patched (${edits.length} functions): ${abs}`);
}
return edits.length;
}
const files = process.argv.slice(2);
if (files.length === 0) {
console.error("usage: node scripts/fix-server-auth-ast.js <file>...");
process.exit(2);
}
let patched = 0, total = 0;
for (const f of files) {
const n = processFile(f);
if (n > 0) patched++;
total += n;
}
console.log(`done — ${patched} files, ${total} functions`);