UNPKG

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