4bnode
Version:
4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.
245 lines (206 loc) • 7.46 kB
JavaScript
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { select, confirm, checkbox } from "@inquirer/prompts";
import { toPascalCase } from "./lib/names.js";
import {
getProjectRoot,
listRoutes,
listModels,
getModelSchema,
} from "./lib/project.js";
import { updateEnvFile } from "./lib/env.js";
import { addRouteRegistration } from "./lib/indexFile.js";
import {
getRouteFilePath,
addImportToRoute,
insertCodeIntoRoute,
} from "./lib/routeFile.js";
import {
showHeader,
showTaskDone,
showError,
showInfo,
showFileAction,
installDeps,
} from "./lib/ui.js";
function addAuthMiddleware() {
const projectRoot = getProjectRoot();
const authDir = path.join(projectRoot, "src", "middleware");
const authFilePath = path.join(authDir, "auth.js");
if (fs.existsSync(authFilePath)) {
showInfo("Auth middleware already exists.");
return;
}
const content = `import jwt from 'jsonwebtoken';
const auth = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ message: 'No token, authorization denied' });
}
try {
// Pin the algorithm (no alg-confusion) and reject non-access tokens.
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
if (decoded.type && decoded.type !== 'access') {
return res.status(401).json({ message: 'Token is not valid' });
}
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ message: 'Token is not valid' });
}
};
export default auth;
`;
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(authFilePath, content, "utf8");
showFileAction("created", "src/middleware/auth.js");
}
function addRefreshTokenRoute() {
const projectRoot = getProjectRoot();
const routesDir = path.join(projectRoot, "src", "routes");
const filePath = path.join(routesDir, "refresh-token.js");
if (fs.existsSync(filePath)) {
showInfo("Refresh token route already exists.");
return;
}
const content = `import express from 'express';
import jwt from 'jsonwebtoken';
const router = express.Router();
router.post('/', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not provided' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
// Only a refresh token may be exchanged — an access token must not work here.
if (decoded.type !== 'refresh') {
return res.status(401).json({ message: 'Invalid refresh token' });
}
const token = jwt.sign({ id: decoded.id, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '15m' });
res.json({ token });
} catch (err) {
console.error(err);
res.status(401).json({ message: 'Invalid refresh token' });
}
});
export default router;
`;
fs.mkdirSync(routesDir, { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
showFileAction("created", "src/routes/refresh-token.js");
addRouteRegistration(
"import refreshToken from './src/routes/refresh-token.js';",
"app.use('/refresh-token', refreshToken);"
);
showFileAction("updated", "index.js");
}
async function main() {
showHeader("add-login", "Setup authentication & login");
const routeFiles = listRoutes();
if (routeFiles.length === 0) {
showError("No route files found in src/routes.");
process.exit(1);
}
const modelFiles = listModels();
if (modelFiles.length === 0) {
showError("No model files found in src/models.");
process.exit(1);
}
const selectedRoute = await select({
message: "Select route file to modify:",
choices: routeFiles.map((f) => ({ name: f, value: f })),
});
const selectedModelFile = await select({
message: "Select model file to use:",
choices: modelFiles.map((f) => ({ name: f, value: f })),
});
const modelName = path.basename(selectedModelFile, ".js");
const pascalName = toPascalCase(modelName);
const schemaFields = getModelSchema(modelName);
if (schemaFields.length === 0) {
showError("No fields found in the selected model.");
process.exit(1);
}
// A login must verify a credential — without a password field the generated
// route would authenticate on the identifier alone (no real check).
if (!schemaFields.includes("password")) {
showError(
"The selected model has no 'password' field. Add one before generating login so credentials can be verified."
);
process.exit(1);
}
const useExpiry = await confirm({
message: "Also issue a refresh token? (15m access + 7d refresh)",
default: true,
});
const selectedFields = await checkbox({
message: "Select login fields:",
choices: schemaFields.map((f) => ({ name: f, value: f })),
validate: (v) =>
v.length === 0
? "Select at least one field."
: !v.includes("password")
? "Include the 'password' field so credentials are verified."
: true,
});
console.log();
const routeFilePath = getRouteFilePath(selectedRoute);
addImportToRoute(
routeFilePath,
`import bcrypt from 'bcrypt';\nimport jwt from 'jsonwebtoken';\nimport ${pascalName} from '../models/${modelName}.js';`
);
const loginCode = `
router.post('/login', async (req, res) => {
const { ${selectedFields.join(", ")} } = req.body;
try {
// String() coerces the identifier so an object like {"$gt":""} can't turn this
// into a NoSQL operator query. .select('+password') pulls the (select:false) hash.
const user = await ${pascalName}.findOne({ ${selectedFields[0]}: String(${selectedFields[0]} ?? '') }).select('+password');
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const isMatch = await bcrypt.compare(String(password ?? ''), user.password || '');
if (!isMatch) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const payload = { id: user._id };
${
useExpiry
? `const token = jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({ token, refreshToken });`
: `const token = jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({ token });`
}
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Server error' });
}
});`;
insertCodeIntoRoute(routeFilePath, loginCode);
showFileAction("updated", `src/routes/${selectedRoute}`);
const projectRoot = getProjectRoot();
const secret = crypto.randomBytes(32).toString("hex");
updateEnvFile(path.join(projectRoot, ".env"), "JWT_SECRET", secret);
showFileAction("updated", ".env");
await installDeps(["jsonwebtoken", "bcrypt"]);
addAuthMiddleware();
if (useExpiry) {
addRefreshTokenRoute();
}
const details = [
`Route: ${selectedRoute}`,
`Model: ${modelName}`,
`Fields: ${selectedFields.join(", ")}`,
useExpiry ? "JWT with 15m expiry + refresh token" : "JWT without expiry",
];
showTaskDone("Login & auth configured", details);
}
main().catch((err) => {
if (err.name === "ExitPromptError") process.exit(0);
showError(err.message);
process.exit(1);
});