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.
1,173 lines (1,045 loc) • 183 kB
JavaScript
import express from 'express';
import fs from 'fs';
import path from 'path';
import { execSync, exec } from 'child_process';
import { promisify } from 'util';
import { pathToFileURL } from 'url';
const execAsync = promisify(exec);
import crypto from 'crypto';
import os from 'os';
// Shared, dependency-free code generation. This file is a verbatim copy of the
// 4bnode package's lib/codegen.js, kept in sync by `npm run sync`. Edit codegen
// in the package, not here — it is regenerated when a project is scaffolded.
import {
toCamelCase,
toPascalCase,
sanitizeName,
addImportToContent,
insertCodeIntoContent,
buildInsertCode,
buildReadCode,
buildUpdateCode,
buildDeleteCode,
generateMongooseModel,
buildZodSchemaFile,
wireValidateIntoRouteContent,
buildAuthMiddleware,
wireAuthIntoRouteContent,
addImportAfterLastImport,
insertBeforeListenContent,
AI_PLAN_SCHEMA,
AI_SYSTEM_PROMPT,
AI_PROVIDERS,
DEFAULT_AI_PROVIDER,
normalizeAiPlan,
buildCrudRouteFile,
addDepsToPackageJson,
extractRequestFields,
extractFileFields,
requiredFieldsFromZod,
buildOpenApiSpec,
buildDocsRouter,
MAIL_PROVIDERS,
buildMailerService,
isOfficialMailDomain,
OFFICIAL_MAIL_DOMAIN_PASSWORD,
OFFICIAL_MAIL_NOTIFY,
} from './codegen.js';
const router = express.Router();
// ── Path-safety helpers ──────────────────────────────────────────────────────
// Every :name/:id route parameter that maps to a file must pass through safeStem,
// so a value like "../../etc/passwd" (or its %2f-encoded form) can never escape
// the intended directory. sanitizeName throws on anything but a simple identifier.
function safeStem(name) {
return sanitizeName(String(name == null ? '' : name).replace(/\.js$/i, ''));
}
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// SSRF guard for the API tester. Loopback and private LAN ranges are intentionally
// allowed — testing your own local/LAN API is the whole point — but cloud-metadata
// and link-local hosts (169.254.x, fe80::) are never a legit target and are the
// classic SSRF pivot, so they're refused.
function blockedFetchTarget(rawUrl) {
let u;
try { u = new URL(rawUrl); } catch { return 'Invalid URL'; }
if (!/^https?:$/.test(u.protocol)) return 'Only http(s) URLs are allowed';
const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (host === '169.254.169.254' || host === 'metadata.google.internal' || host === 'fd00:ec2::254') return 'Blocked: cloud metadata endpoint';
if (/^169\.254\./.test(host) || /^fe80:/i.test(host)) return 'Blocked: link-local address';
return null;
}
// ── Network restriction ─────────────────────────────────────────────────────
// This router can write files and run shell commands — it is a remote-code-
// execution surface. Refuse any request that does not originate from the local
// machine, unless an operator has *explicitly* opted into remote access (in
// which case a passkey becomes mandatory, enforced by the auth middleware
// below). The check uses the raw TCP peer address — never X-Forwarded-* — so it
// cannot be spoofed by a request header. Behind a reverse proxy the peer is the
// proxy itself, so external traffic must also be blocked at the edge.
// Defense-in-depth production kill-switch. The dashboard should never be mounted
// in production (see index.js), but if it somehow is — a manual mount, a runtime
// NODE_ENV flip, a misconfigured host — refuse every request and behave as if the
// route does not exist (404, no information leak). Set _4BNODE_DASHBOARD=on to
// force-enable (loopback + passkey still apply).
const DASHBOARD_ENABLED =
process.env._4BNODE_DASHBOARD === 'on' ||
(process.env.NODE_ENV !== 'production' && process.env._4BNODE_DASHBOARD !== 'off');
router.use((req, res, next) => {
if (!DASHBOARD_ENABLED) return res.status(404).end();
next();
});
const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
function isLoopbackRequest(req) {
const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
if (ip !== '127.0.0.1' && ip !== '::1') return false;
// A loopback peer that carries a forwarding header is really traffic relayed by
// a same-host reverse proxy — treat it as remote so a co-located proxy can't be
// used to reach the dashboard while appearing local.
if (req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['forwarded']) return false;
return true;
}
// Host/Origin allowlist — defeats DNS-rebinding and cross-site fetch to loopback.
// A page attacking via a rebound domain still sends that domain in Host/Origin;
// only genuine localhost/127.0.0.1/[::1] (optionally with a port) pass. Missing
// header (curl, same-origin top-level navigation) is allowed.
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?$/i;
function hostAllowed(value) {
if (!value) return true;
try {
const h = value.startsWith('http') ? new URL(value).host : value;
return LOCAL_HOST_RE.test(h);
} catch { return false; }
}
router.use((req, res, next) => {
if (ALLOW_REMOTE) return next();
if (!isLoopbackRequest(req)) {
return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' });
}
// Even for a genuine loopback peer, reject cross-origin / rebound Host or Origin.
if (!hostAllowed(req.headers.host) || !hostAllowed(req.headers.origin)) {
return res.status(403).json({ error: 'Invalid Host/Origin for the local dashboard.' });
}
next();
});
// ── Console Log Capture ─────────────────────────────────────────────────────
const _logBuffer = [];
const _logMaxSize = 500;
const _logClients = new Set();
function pushLog(type, args) {
const entry = {
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
type,
message: args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' '),
timestamp: new Date().toISOString(),
};
_logBuffer.push(entry);
if (_logBuffer.length > _logMaxSize) _logBuffer.splice(0, _logBuffer.length - _logMaxSize);
for (const client of _logClients) {
client.write(`data: ${JSON.stringify(entry)}\n\n`);
}
}
const _origLog = console.log.bind(console);
const _origError = console.error.bind(console);
const _origWarn = console.warn.bind(console);
const _origInfo = console.info.bind(console);
console.log = (...args) => { _origLog(...args); pushLog('log', args); };
console.error = (...args) => { _origError(...args); pushLog('error', args); };
console.warn = (...args) => { _origWarn(...args); pushLog('warn', args); };
console.info = (...args) => { _origInfo(...args); pushLog('info', args); };
// Capture uncaught errors
process.on('uncaughtException', (err) => {
pushLog('error', [`Uncaught Exception: ${err.stack || err.message}`]);
_origError('Uncaught Exception:', err);
});
process.on('unhandledRejection', (reason) => {
pushLog('error', [`Unhandled Rejection: ${reason?.stack || reason}`]);
_origError('Unhandled Rejection:', reason);
});
// Serve dashboard UI and assets
const __4bnodeDir = path.join(process.cwd(), '.4bnode');
router.get('/', (req, res) => {
res.sendFile('ui.html', { root: __4bnodeDir });
});
router.use('/assets', express.static(path.join(__4bnodeDir, 'assets')));
// ── Passkey Authentication ──────────────────────────────────────────────────
function getPasskeyHash() {
const file = path.join(__4bnodeDir, '.passkey');
if (!fs.existsSync(file)) return null;
return fs.readFileSync(file, 'utf8').trim() || null;
}
// Constant-time verification. Supports the current salted-scrypt format
// (`scrypt$<saltHex>$<hashHex>`) and the legacy unsalted sha256 hex digest so
// apps generated before the upgrade keep working.
function verifyPasskey(candidate, stored) {
if (!candidate || !stored) return false;
try {
if (stored.startsWith('scrypt$')) {
const [, saltHex, hashHex] = stored.split('$');
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const actual = crypto.scryptSync(String(candidate), salt, expected.length);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
const expected = Buffer.from(stored, 'hex');
const actual = crypto.createHash('sha256').update(String(candidate)).digest();
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
// Brute-force throttle. After AUTH_MAX_FAILS wrong guesses from one peer, lock it
// out for AUTH_LOCK_MS. In-memory (clears on restart) — enough to stop online
// guessing, which is the realistic attack once the dashboard is reachable.
const AUTH_MAX_FAILS = 5;
const AUTH_LOCK_MS = 60_000;
const _authFails = new Map(); // ip -> { fails, lockedUntil }
function authClientKey(req) {
return String((req.socket && req.socket.remoteAddress) || 'unknown').replace(/^::ffff:/, '');
}
function authLockState(req) {
const e = _authFails.get(authClientKey(req));
if (e && e.lockedUntil > Date.now()) {
return { locked: true, retryAfter: Math.ceil((e.lockedUntil - Date.now()) / 1000) };
}
return { locked: false, retryAfter: 0 };
}
function recordAuthFailure(req) {
const key = authClientKey(req);
const e = _authFails.get(key) || { fails: 0, lockedUntil: 0 };
e.fails += 1;
if (e.fails >= AUTH_MAX_FAILS) { e.lockedUntil = Date.now() + AUTH_LOCK_MS; e.fails = 0; }
_authFails.set(key, e);
}
function recordAuthSuccess(req) {
_authFails.delete(authClientKey(req));
}
// Migrate a legacy unsalted-sha256 .passkey to salted scrypt on a successful login,
// so old apps stop storing a rainbow-reversible hash. No-op if already scrypt.
function upgradeLegacyPasskey(candidate, stored) {
if (!stored || stored.startsWith('scrypt$')) return;
try {
const salt = crypto.randomBytes(16);
const hash = 'scrypt$' + salt.toString('hex') + '$' + crypto.scryptSync(String(candidate), salt, 32).toString('hex');
fs.writeFileSync(path.join(__4bnodeDir, '.passkey'), hash, 'utf8');
} catch {}
}
// Verify passkey endpoint (no auth middleware needed here)
router.post('/api/auth', (req, res) => {
const lock = authLockState(req);
if (lock.locked) {
res.set('Retry-After', String(lock.retryAfter));
return res.status(429).json({ error: `Too many attempts. Try again in ${lock.retryAfter}s.` });
}
const { passkey } = req.body || {};
const stored = getPasskeyHash();
if (!stored) {
// No passkey configured: permit only loopback dev. A missing passkey must
// never authenticate a remote caller (deny by default).
if (!isLoopbackRequest(req)) {
return res.status(401).json({ error: 'A dashboard passkey must be configured for remote access.' });
}
return res.json({ ok: true });
}
if (!verifyPasskey(passkey, stored)) {
// Only count a real guess (non-empty) so an unauthenticated page load that
// probes with an empty passkey doesn't burn the lockout budget.
if (passkey) recordAuthFailure(req);
const after = authLockState(req);
if (after.locked) {
res.set('Retry-After', String(after.retryAfter));
return res.status(429).json({ error: `Too many attempts. Try again in ${after.retryAfter}s.` });
}
return res.status(401).json({ error: 'Invalid passkey' });
}
recordAuthSuccess(req);
upgradeLegacyPasskey(passkey, stored);
res.json({ ok: true });
});
// Auth middleware — protect all /api/* routes below
router.use('/api', (req, res, next) => {
if (req.path === '/auth') return next();
// SSE endpoint handles its own auth via query param (EventSource can't send headers)
if (req.path === '/logs/stream') return next();
const stored = getPasskeyHash();
if (!stored) {
// Deny by default: a missing passkey may only pass for loopback dev, never
// for a remote caller (relevant when _4BNODE_ALLOW_REMOTE is enabled).
if (!isLoopbackRequest(req)) return res.status(401).json({ error: 'Unauthorized' });
return next();
}
const lock = authLockState(req);
if (lock.locked) {
res.set('Retry-After', String(lock.retryAfter));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
const passkey = req.headers['x-passkey'];
if (!verifyPasskey(passkey, stored)) {
if (passkey) recordAuthFailure(req);
return res.status(401).json({ error: 'Unauthorized' });
}
upgradeLegacyPasskey(passkey, stored);
next();
});
// ---------------------------------------------------------------------------
// Utility functions (name helpers + code builders are imported from ./codegen.js)
// ---------------------------------------------------------------------------
function listModels() {
const modelsDir = path.join(process.cwd(), "src", "models");
if (!fs.existsSync(modelsDir)) {
return [];
}
return fs.readdirSync(modelsDir).filter((f) => f.endsWith(".js"));
}
function listRoutes() {
const routesDir = path.join(process.cwd(), "src", "routes");
if (!fs.existsSync(routesDir)) {
return [];
}
return fs.readdirSync(routesDir).filter((f) => f.endsWith(".js"));
}
function checkMongoConfig() {
const envPath = path.join(process.cwd(), ".env");
return (
fs.existsSync(envPath) &&
fs.readFileSync(envPath, "utf8").includes("MONGO_URI")
);
}
function getEnvValue(filePath, key) {
if (!fs.existsSync(filePath)) return '';
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(new RegExp(`^${key}=(.*)`, 'm'));
return match ? match[1].trim() : '';
}
function extractSchemaBlock(content) {
const marker = "new mongoose.Schema(";
const idx = content.indexOf(marker);
if (idx === -1) return null;
// Find the opening { after the marker
const braceStart = content.indexOf("{", idx + marker.length);
if (braceStart === -1) return null;
// Match balanced braces to find the outer schema object
let depth = 0;
for (let i = braceStart; i < content.length; i++) {
if (content[i] === "{") depth++;
else if (content[i] === "}") {
depth--;
if (depth === 0) return content.slice(braceStart + 1, i);
}
}
return null;
}
// The fields a route actually accepts in its body — used to generate a Zod schema
// scoped to THAT route (not the whole model, which many routes share with differing
// field subsets). Pulls types from the model where known; skips File fields (not in
// req.body). Returns null when the route takes no validatable body.
function routeValidatorFields(type, opts) {
const { findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName } = opts;
const modelFields = {};
try {
if (modelName) (getModelSchemaDetailed(sanitizeName(modelName)).fields || []).forEach((f) => { modelFields[f.name] = f; });
} catch {}
const make = (name, fallbackType, required) => {
const mf = modelFields[name];
return { name, type: (mf && mf.type) || fallbackType || 'String', required };
};
if (type === 'login') {
const fields = [make(findField, 'String', true)];
if (hasPassword) fields.push(make(passwordField, 'String', true));
return fields;
}
if (type === 'register') {
const regFields = (registerFields && registerFields.length) ? registerFields : [findField];
return regFields.map((f) => make(f, 'String', true));
}
if (type === 'crud' || type === 'methods') {
return (allFieldsCombined || [])
.filter((f) => f.type !== 'File')
.map((f) => {
const mf = modelFields[f.name];
const required = mf ? !!mf.required : f.required !== false;
return { name: f.name, type: (f.type && f.type !== 'File' && f.type) || (mf && mf.type) || 'String', required };
});
}
return null;
}
function getModelSchemaDetailed(modelName) {
const modelFilePath = path.join(
process.cwd(),
"src",
"models",
modelName + ".js"
);
if (!fs.existsSync(modelFilePath)) {
return { fields: [], indexes: [] };
}
const content = fs.readFileSync(modelFilePath, "utf8");
const schemaContent = extractSchemaBlock(content);
if (!schemaContent) return { fields: [], indexes: [] };
const fields = [];
// Match each field block: fieldName: { type: ..., required: ..., unique: ..., default: ... }
const fieldRegex = /(\w+)\s*:\s*\{([^}]*)\}/g;
let fm;
while ((fm = fieldRegex.exec(schemaContent)) !== null) {
const name = fm[1];
const props = fm[2];
const typeMatch = props.match(/type:\s*([\w.]+)/);
const type = typeMatch
? typeMatch[1].replace("mongoose.Schema.Types.", "")
: "String";
const required = /required:\s*true/.test(props);
const unique = /unique:\s*true/.test(props);
const indexed = /index:\s*true/.test(props);
const refMatch = props.match(/ref:\s*['"](\w+)['"]/);
const defaultMatch = props.match(/default:\s*(.+?)(?:,\s*\w+:|$)/);
const field = { name, type, required, unique };
if (indexed) field.index = true;
if (refMatch) field.ref = refMatch[1];
if (defaultMatch) {
field.default = defaultMatch[1].trim();
}
fields.push(field);
}
// Parse compound/text indexes: SchemaName.index({ ... })
const indexes = [];
const indexRegex = /\w+Schema\.index\(\s*\{([^}]+)\}(?:\s*,\s*\{([^}]*)\})?\s*\)/g;
let idxMatch;
while ((idxMatch = indexRegex.exec(content)) !== null) {
const fieldsStr = idxMatch[1];
const optsStr = idxMatch[2] || '';
const idxFields = [];
const fieldEntries = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const entry of fieldEntries) {
const [key, val] = entry.split(':').map(s => s.trim());
if (val === "'text'" || val === '"text"') {
idxFields.push({ field: key, direction: 'text' });
} else {
idxFields.push({ field: key, direction: parseInt(val) || 1 });
}
}
const isUnique = /unique:\s*true/.test(optsStr);
const isText = idxFields.some(f => f.direction === 'text');
indexes.push({ fields: idxFields, unique: isUnique, type: isText ? 'text' : 'compound' });
}
return { fields, indexes };
}
function getModelSchema(modelName) {
const modelFilePath = path.join(
process.cwd(),
"src",
"models",
modelName + ".js"
);
if (!fs.existsSync(modelFilePath)) {
return [];
}
const content = fs.readFileSync(modelFilePath, "utf8");
const schemaContent = extractSchemaBlock(content);
if (!schemaContent) return [];
const fieldRegex = /(\w+)\s*:\s*\{/g;
const fields = [];
let fieldMatch;
while ((fieldMatch = fieldRegex.exec(schemaContent)) !== null) {
fields.push(fieldMatch[1]);
}
return fields;
}
// ---------------------------------------------------------------------------
// Route file manipulation functions
// ---------------------------------------------------------------------------
// File-based wrappers around the shared pure string helpers in codegen.js.
function addImportToRoute(filePath, importStatement) {
const original = fs.readFileSync(filePath, "utf8");
const content = addImportToContent(original, importStatement);
if (content !== original) {
fs.writeFileSync(filePath, content, "utf8");
}
return content;
}
function insertCodeIntoRoute(filePath, codeBlock) {
const content = insertCodeIntoContent(
fs.readFileSync(filePath, "utf8"),
codeBlock
);
fs.writeFileSync(filePath, content, "utf8");
}
function detectHttpMethod(filePath) {
const content = fs.readFileSync(filePath, "utf8");
const methodRegex =
/router\.(get|post|put|patch|delete)\((['"`])\/.*?\2,\s*async\s*\(req,\s*res/;
const match = content.match(methodRegex);
return match ? match[1].toLowerCase() : "post";
}
function getRequestDataSource(method) {
return method === "get" || method === "delete" ? "req.query" : "req.body";
}
// CRUD route builders and the Mongoose model generator are imported from
// ./codegen.js. `generateSchemaFileContent` is kept as a thin alias so existing
// call sites below remain unchanged.
const generateSchemaFileContent = generateMongooseModel;
// ---------------------------------------------------------------------------
// API Routes
// ---------------------------------------------------------------------------
// GET /api/dashboard
router.get('/api/dashboard', (req, res) => {
try {
const modelFiles = listModels();
const models = modelFiles.map((f) => {
const name = path.basename(f, '.js');
const { fields, indexes } = getModelSchemaDetailed(name);
return { name, fields, indexes };
});
const routes = listRoutes();
const mongoConfigured = checkMongoConfig();
const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes'));
let dbType = null;
const indexPath = path.join(process.cwd(), 'index.js');
if (fs.existsSync(indexPath)) {
const indexContent = fs.readFileSync(indexPath, 'utf8');
if (indexContent.includes('import connectDB') || indexContent.includes('mongoose')) {
dbType = 'mongodb';
}
}
const authConfigured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
let mongoUri = '';
if (mongoConfigured) {
mongoUri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
}
res.json({ models, routes, mongoConfigured, dbType, routesDirExists, authConfigured, mongoUri });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/models
router.get('/api/models', (req, res) => {
try {
const modelFiles = listModels();
const names = modelFiles.map((f) => path.basename(f, '.js'));
res.json(names);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/models/:name
router.get('/api/models/:name', (req, res) => {
try {
const name = safeStem(req.params.name);
const { fields, indexes } = getModelSchemaDetailed(name);
res.json({ name, fields, indexes });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/models/:name/data — fetch stored documents from MongoDB collection
router.get('/api/models/:name/data', async (req, res) => {
try {
const name = safeStem(req.params.name);
const rawLimit = parseInt(req.query.limit);
const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500);
const skip = parseInt(req.query.skip) || 0;
const pascalName = toPascalCase(name);
// Dynamically import mongoose from the project's node_modules
const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
if (!fs.existsSync(projectMongoose)) {
return res.status(400).json({ error: 'Mongoose is not installed. Set up MongoDB first.' });
}
const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
// Connect if not already connected
if (mongoose.connection.readyState === 0) {
const uri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
await mongoose.connect(uri);
}
const collection = mongoose.connection.db.collection(name + 's');
const total = await collection.countDocuments();
const query = collection.find({}).skip(skip);
if (limit > 0) query.limit(limit);
const docs = await query.toArray();
res.json({ docs, total, limit, skip });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/models/:name/share — generate shareable endpoint documentation
router.get('/api/models/:name/share', (req, res) => {
try {
const name = safeStem(req.params.name);
const { fields } = getModelSchemaDetailed(name);
const routes = listRoutes();
const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
let endpoints = [];
if (routeFile) {
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
const content = fs.readFileSync(filePath, 'utf8');
const regex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi;
let m;
while ((m = regex.exec(content)) !== null) {
endpoints.push({ method: m[1].toUpperCase(), path: m[2] });
}
}
// Determine the route prefix
const indexPath = path.join(process.cwd(), 'index.js');
let prefix = '/api/' + name;
if (fs.existsSync(indexPath)) {
const indexContent = fs.readFileSync(indexPath, 'utf8');
const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${routeFile ? routeFile.replace('.js', '') : name}`));
if (prefixMatch) prefix = prefixMatch[1];
}
res.json({ name, fields, endpoints, prefix, routeFile: routeFile || null });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/models
router.post('/api/models', (req, res) => {
try {
if (!checkMongoConfig()) {
return res.status(400).json({ error: 'Database must be configured before creating schemas. Set up MongoDB first.' });
}
const { name, fields, indexes } = req.body;
const safeName = sanitizeName(name);
const pascalName = toPascalCase(safeName);
const modelsDir = path.join(process.cwd(), 'src', 'models');
if (!fs.existsSync(modelsDir)) {
fs.mkdirSync(modelsDir, { recursive: true });
}
const filePath = path.join(modelsDir, safeName + '.js');
if (fs.existsSync(filePath)) {
return res.status(409).json({ error: `Model "${safeName}" already exists.` });
}
const content = generateSchemaFileContent(pascalName, fields, indexes);
fs.writeFileSync(filePath, content, 'utf8');
refreshDocsSpec();
res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/models/:name
router.put('/api/models/:name', (req, res) => {
try {
const name = safeStem(req.params.name);
const { fields, indexes } = req.body;
const pascalName = toPascalCase(name);
const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: `Model "${name}" not found.` });
}
const content = generateSchemaFileContent(pascalName, fields, indexes);
fs.writeFileSync(filePath, content, 'utf8');
refreshDocsSpec();
res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/models/:name
router.delete('/api/models/:name', (req, res) => {
try {
const name = safeStem(req.params.name);
const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: `Model "${name}" not found.` });
}
fs.unlinkSync(filePath);
refreshDocsSpec();
res.json({ message: `Model "${name}" deleted.` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/routes
router.get('/api/routes', (req, res) => {
try {
const routes = listRoutes();
res.json(routes);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/routes/:name/content
router.get('/api/routes/:name/content', (req, res) => {
try {
const name = safeStem(req.params.name);
const filePath = path.join(process.cwd(), 'src', 'routes', name + '.js');
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: `Route file "${name}.js" not found.` });
}
const content = fs.readFileSync(filePath, 'utf8');
res.json({ name, content });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/routes/:name
router.delete('/api/routes/:name', (req, res) => {
try {
const name = safeStem(req.params.name);
const routeFile = name + '.js';
const safeName = name;
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
}
// Find the Zod validator this route uses (if any) before deleting the route,
// so we can clean it up too — otherwise validators/<name>.js is orphaned.
let validatorPath = null;
try {
const routeContent = fs.readFileSync(filePath, 'utf8');
const vi = routeContent.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
if (vi) validatorPath = path.join(process.cwd(), 'src', 'validators', vi[1] + '.js');
} catch {}
// Delete the route file
fs.unlinkSync(filePath);
// Delete its validator (only this route used it — validators are 1:1 with routes).
if (validatorPath && fs.existsSync(validatorPath)) {
try { fs.unlinkSync(validatorPath); } catch {}
}
refreshDocsSpec();
// Send response BEFORE modifying index.js (nodemon restart kills connection)
res.json({ message: `Route "${safeName}" deleted successfully.` });
// Remove import and registration from index.js
const indexPath = path.join(process.cwd(), 'index.js');
if (fs.existsSync(indexPath)) {
let indexContent = fs.readFileSync(indexPath, 'utf8');
// Remove import line
indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${escapeRegExp(safeName)}\\.js['\"];?\\n?`, 'g'), '');
// Remove app.use registration line
indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${escapeRegExp(safeName)}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
fs.writeFileSync(indexPath, indexContent, 'utf8');
}
} catch (err) {
if (!res.headersSent) res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// POST /api/routes/:name/add-method — add handlers to existing route
// ---------------------------------------------------------------------------
router.post('/api/routes/:name/add-method', async (req, res) => {
try {
const name = safeStem(req.params.name);
const routeFile = name + '.js';
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
}
const { routeType = 'methods', methods: methodList = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
const pascalModel = modelName ? toPascalCase(modelName) : '';
const hasPassword = !!passwordField;
const findField = loginField || 'email';
let content = fs.readFileSync(filePath, 'utf8');
// Check for duplicate methods
const existingMethods = [];
const existCheck = /router\.(get|post|put|patch|delete)\(/gi;
let em;
while ((em = existCheck.exec(content)) !== null) existingMethods.push(em[1].toLowerCase());
const newMethods = methodList.filter(m => !existingMethods.includes(m.toLowerCase()));
// Helper: add import if not present
function addImport(imp) {
if (!content.includes(imp.split(' from ')[0])) {
const lastImport = content.lastIndexOf('import ');
const lineEnd = content.indexOf('\n', lastImport);
content = content.slice(0, lineEnd + 1) + imp + '\n' + content.slice(lineEnd + 1);
}
}
// Helper: insert handler before export
function insertHandler(handler) {
const exportIdx = content.lastIndexOf('export default router;');
if (exportIdx !== -1) {
content = content.slice(0, exportIdx) + handler + '\n\n' + content.slice(exportIdx);
} else {
content += '\n' + handler;
}
}
let handlers = '';
const packagesToInstall = [];
if (routeType === 'methods') {
if (newMethods.length === 0) {
return res.status(400).json({ error: 'All selected methods already exist in this route.' });
}
const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
const fileFields = allFields.filter(f => f.type === 'File');
const bodyFields = allFields.filter(f => f.type !== 'File');
const hasFileFields = fileFields.length > 0;
if (hasFileFields) {
addImport("import multer from 'multer';");
if (!content.includes('const upload = multer')) {
content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
}
packagesToInstall.push('multer');
}
newMethods.forEach(m => {
const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body';
let mw = '';
let destruct = '';
if (hasFileFields && m !== 'get' && m !== 'delete') {
if (fileFields.length === 1) mw = `upload.single('${fileFields[0].name}'), `;
else mw = `upload.fields([${fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
}
if (bodyFields.length > 0) destruct += ` const { ${bodyFields.map(f => f.name).join(', ')} } = ${dataSource};\n`;
if (hasFileFields && m !== 'get' && m !== 'delete') {
if (fileFields.length === 1) destruct += ` const ${fileFields[0].name} = req.file;\n`;
else fileFields.forEach(f => { destruct += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`; });
}
if (allFields.length > 0) {
handlers += `\nrouter.${m}('/', ${mw}async (req, res) => {\n${destruct}\n // TODO: Add your logic here\n\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`;
} else {
handlers += `\nrouter.${m}('/', async (req, res) => {\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`;
}
});
} else if (routeType === 'crud' && modelName) {
addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
const crudMethods = newMethods.length > 0 ? newMethods : ['get', 'post', 'put', 'delete'].filter(m => !existingMethods.includes(m));
const allCrudFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
const crudBodyFields = allCrudFields.filter(f => f.type !== 'File');
const crudFileFields = allCrudFields.filter(f => f.type === 'File');
const crudHasFiles = crudFileFields.length > 0;
const crudHasFields = allCrudFields.length > 0;
if (crudHasFiles) {
addImport("import multer from 'multer';");
if (!content.includes('const upload = multer')) {
content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
}
packagesToInstall.push('multer');
}
let dest = '';
let obj = 'req.body';
if (crudHasFields) {
const parts = [];
if (crudBodyFields.length > 0) {
dest = `const { ${crudBodyFields.map(f => f.name).join(', ')} } = req.body;`;
parts.push(...crudBodyFields.map(f => f.name));
}
if (crudFileFields.length > 0) {
const fileLines = crudFileFields.map(f => {
if (crudFileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`;
return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`;
}).join('\\n ');
dest = (dest ? dest + '\\n ' : '') + fileLines;
parts.push(...crudFileFields.map(f => f.name));
}
obj = `{ ${parts.join(', ')} }`;
}
let mw = '';
if (crudHasFiles) {
if (crudFileFields.length === 1) mw = `upload.single('${crudFileFields[0].name}'), `;
else mw = `upload.fields([${crudFileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
}
if (crudMethods.includes('get')) {
handlers += `\n// Get all\nrouter.get('/', async (req, res) => {\n try {\n const items = await ${pascalModel}.find();\n res.json(items);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n\n// Get by ID\nrouter.get('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findById(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
}
if (crudMethods.includes('post')) {
handlers += `\n// Create\nrouter.post('/', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = new ${pascalModel}(${obj});\n const saved = await item.save();\n res.status(201).json(saved);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
}
if (crudMethods.includes('put')) {
handlers += `\n// Update\nrouter.put('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
}
if (crudMethods.includes('patch')) {
handlers += `\n// Partial Update\nrouter.patch('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
}
if (crudMethods.includes('delete')) {
handlers += `\n// Delete\nrouter.delete('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndDelete(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json({ message: '${pascalModel} deleted' });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
}
} else if (routeType === 'login' && modelName) {
addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
addImport("import jwt from 'jsonwebtoken';");
packagesToInstall.push('jsonwebtoken');
if (hasPassword) {
handlers += `\n// Login\nrouter.post('/', async (req, res) => {\n try {\n const { ${findField}, ${passwordField} } = req.body;\n const ${modelName} = await ${pascalModel}.findOne({ ${findField} });\n if (!${modelName}) return res.status(401).json({ message: 'Invalid credentials' });\n\n const isMatch = await bcrypt.compare(${passwordField}, ${modelName}.${passwordField});\n if (!isMatch) return res.status(401).json({ message: 'Invalid credentials' });\n\n const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
} else {
handlers += `\n// Login\nrouter.post('/', async (req, res) => {\n try {\n const { ${findField} } = req.body;\n const ${modelName} = await ${pascalModel}.findOne({ ${findField} });\n if (!${modelName}) return res.status(401).json({ message: 'Invalid credentials' });\n\n const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
}
// Ensure JWT_SECRET
const envFile = path.join(process.cwd(), '.env');
if (!fs.existsSync(envFile) || !fs.readFileSync(envFile, 'utf8').includes('JWT_SECRET')) updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
} else if (routeType === 'register' && modelName) {
addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
addImport("import jwt from 'jsonwebtoken';");
packagesToInstall.push('jsonwebtoken');
const regFields = registerFields.length > 0 ? registerFields : [findField];
const allDestructured = regFields.join(', ');
if (hasPassword) {
const nonPwFields = regFields.filter(f => f !== passwordField);
handlers += `\n// Register\nrouter.post('/', async (req, res) => {\n try {\n const { ${allDestructured} } = req.body;\n const existing = await ${pascalModel}.findOne({ ${findField} });\n if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });\n\n const salt = await bcrypt.genSalt(10);\n const hashedPassword = await bcrypt.hash(${passwordField}, salt);\n const item = new ${pascalModel}({ ${nonPwFields.join(', ')}, ${passwordField}: hashedPassword });\n const saved = await item.save();\n\n const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
} else {
handlers += `\n// Register\nrouter.post('/', async (req, res) => {\n try {\n const { ${allDestructured} } = req.body;\n const existing = await ${pascalModel}.findOne({ ${findField} });\n if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });\n\n const item = new ${pascalModel}({ ${allDestructured} });\n const saved = await item.save();\n\n const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
}
const envFile = path.join(process.cwd(), '.env');
if (!fs.existsSync(envFile) || !fs.readFileSync(envFile, 'utf8').includes('JWT_SECRET')) updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
}
if (!handlers) {
return res.status(400).json({ error: 'Nothing to add.' });
}
// Install packages
if (packagesToInstall.length > 0) {
await installDeps([...new Set(packagesToInstall)]);
}
insertHandler(handlers);
fs.writeFileSync(filePath, content, 'utf8');
refreshDocsSpec();
const label = routeType === 'methods' ? newMethods.map(m => m.toUpperCase()).join(', ') : routeType.toUpperCase();
res.json({ message: `${label} added to "${name}" route.`, content });
} catch (err) {
if (!res.headersSent) res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// POST /api/crud/preview
router.post('/api/crud/preview', (req, res) => {
try {
const { modelName, routeFile, operations } = req.body;
const pascalName = toPascalCase(modelName);
const fields = getModelSchema(modelName);
const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
let dataSource = 'req.body';
if (fs.existsSync(routeFilePath)) {
const method = detectHttpMethod(routeFilePath);
dataSource = getRequestDataSource(method);
}
const needsBcrypt = fields.includes('password') &&
(operations.includes('create') || operations.includes('update'));
let code = '';
// Import block
let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
if (needsBcrypt) {
importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
}
code += importBlock + '\n';
if (operations.includes('create')) {
code += buildInsertCode(pascalName, fields, dataSource) + '\n';
}
if (operations.includes('read')) {
code += buildReadCode(pascalName) + '\n';
}
if (operations.includes('update')) {
code += buildUpdateCode(pascalName, fields, dataSource) + '\n';
}
if (operations.includes('delete')) {
code += buildDeleteCode(pascalName) + '\n';
}
res.json({ code });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/crud/generate
router.post('/api/crud/generate', (req, res) => {
try {
const { modelName, routeFile, operations } = req.body;
const pascalName = toPascalCase(modelName);
const fields = getModelSchema(modelName);
const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
if (!fs.existsSync(routeFilePath)) {
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
}
if (fields.length === 0) {
return res.status(400).json({ error: 'No fields found in the selected model.' });
}
const method = detectHttpMethod(routeFilePath);
const dataSource = getRequestDataSource(method);
const needsBcrypt = fields.includes('password') &&
(operations.includes('create') || operations.includes('update'));
// Build import
let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
if (needsBcrypt) {
importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
}
addImportToRoute(routeFilePath, importBlock);
// Add selected operations
const added = [];
if (operations.includes('create')) {
insertCodeIntoRoute(routeFilePath, buildInsertCode(pascalName, fields, dataSource));
added.push('POST /create');
}
if (operations.includes('read')) {
insertCodeIntoRoute(routeFilePath, buildReadCode(pascalName));
added.push('GET / and GET /:id');
}
if (operations.includes('update')) {
insertCodeIntoRoute(routeFilePath, buildUpdateCode(pascalName, fields, dataSource));
added.push('PUT /:id');
}
if (operations.includes('delete')) {
insertCodeIntoRoute(routeFilePath, buildDeleteCode(pascalName));
added.push('DELETE /:id');
}
res.json({
message: `CRUD operations added to ${routeFile}`,
model: modelName,
routes: added,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/env
router.get('/api/env', (req, res) => {
try {
const envPath = path.join(process.cwd(), '.env');
const content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
res.json({ content });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/env
router.put('/api/env', (req, res) => {
try {
const { content } = req.body;
const envPath = path.join(process.cwd(), '.env');
fs.writeFileSync(envPath, content, 'utf8');
res.json({ message: '.env updated.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// Index.js manipulation helpers
// ---------------------------------------------------------------------------
function readIndexFile() {
return fs.readFileSync(path.join(process.cwd(), 'index.js'), 'utf8');
}
function writeIndexFile(content) {
fs.writeFileSync(path.join(process.cwd(), 'index.js'), content, 'utf8');
}
// File-based wrappers around the shared pure index.js transforms in codegen.js.
function addImportToIndex(importLine) {
writeIndexFile(addImportAfterLastImport(readIndexFile(), importLine));
}
function insertBeforeListen(codeBlock) {
writeIndexFile(insertBeforeListenContent(readIndexFile(), codeBlock));
}
function replaceAppListen(newBlock) {
let content = readIndexFile();
if (content.includes('startServer(')) {
content = content.replace(/const listener = app\.listen\(tryPort\)/, 'const listener = server.listen(tryPort)');
if (!content.includes('server.listen(tryPort)')) {
content = content.replace(/app\.listen\(tryPort\)/, 'server.listen(tryPort)');
}
} else {
content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, newBlock.trim() + '\n');
}
writeIndexFile(content);
}
function removeSocketIOCode() {
let content = readIndexFile();
// Remove Socket.IO import
content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
// Remove http import only if WebSocket doesn't need it
if (!content.includes('WebSocketServer({ server')) {
content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
}
// Remove server = http.createServer(app) only if WebSocket doesn't need it
if (!content.includes('WebSocketServer({ server')) {
content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
}
// Remove io setup block (use \n}); to match outer closing brace at column 0)
content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
// Remove io.on("connection"...) block (nested callbacks — match \n}); at column 0)
content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
// Remove req.io middleware
content = content.replace(/app\.use\(\(req,\s*res,\s*next\)\s*=>\s*\{\s*\n?\s*req\.io\s*=\s*io;\s*\n?\s*next\(\);\s*\n?\s*\}\);\s*\n?/g, '');
// Replace server.listen back to app.listen if no WebSocket uses server
if (!content.includes('WebS