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,172 lines (1,085 loc) • 85.9 kB
JavaScript
// ─────────────────────────────────────────────────────────────────────────────
// 4bnode shared code generation
//
// SINGLE SOURCE OF TRUTH for code that is emitted into generated projects.
//
// This module is PURE: no `fs`, no `path`, no npm dependencies, no I/O. Every
// export takes plain values and returns strings. That purity is what lets the
// same file run in two very different places:
//
// 1. The 4bnode CLI (add-*.js, index.js) — imports it from `./lib/codegen.js`.
// 2. The in-project dashboard (.4bnode/dev-api.js) — imports a SYNCED COPY at
// `./codegen.js`, because it runs inside the user's project where the
// package's lib/ does not exist.
//
// The copy in skeleton/.4bnode/codegen.js is generated by `lib/sync-skeleton.js`
// (npm run sync). Do NOT edit that copy by hand — edit THIS file and re-sync.
// ─────────────────────────────────────────────────────────────────────────────
// ── Name helpers ────────────────────────────────────────────────────────────
export function toCamelCase(str) {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
}
export function toPascalCase(str) {
const camel = toCamelCase(str);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}
export function sanitizeName(input) {
if (!input || typeof input !== "string") {
throw new Error("Name is required.");
}
const trimmed = input.trim();
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
throw new Error(
`Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
);
}
return trimmed;
}
// Validate a model/route FIELD name before it is interpolated into generated code.
// A field name becomes a JS identifier (destructuring, object keys), so anything
// but a plain identifier could break out of the template or pollute a prototype.
export function sanitizeFieldName(input) {
if (!input || typeof input !== "string") {
throw new Error("Field name is required.");
}
const trimmed = input.trim();
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
throw new Error(
`Invalid field name "${trimmed}". Use letters, numbers, and underscores; start with a letter or underscore.`
);
}
if (trimmed === "__proto__" || trimmed === "constructor" || trimmed === "prototype") {
throw new Error(`Field name "${trimmed}" is reserved.`);
}
return trimmed;
}
// ── Pure string helpers for route files ─────────────────────────────────────
// These operate on file CONTENT (a string) and return new content. The
// file-reading/writing wrappers live in lib/routeFile.js (CLI) and inline in
// dev-api.js (dashboard); both delegate here so the insertion logic is shared.
export function addImportToContent(content, importStatement) {
const stmt = importStatement.trim();
if (content.includes(stmt)) return content;
return stmt + "\n" + content;
}
export function insertCodeIntoContent(content, codeBlock) {
const block = codeBlock.trim();
// Prefer inserting before "export default router;" — most reliable anchor.
const exportIndex = content.lastIndexOf("export default router;");
if (exportIndex !== -1) {
return (
content.slice(0, exportIndex) +
block +
"\n\n" +
content.slice(exportIndex)
);
}
// Fallback: before the last "});".
const lastClose = content.lastIndexOf("});");
if (lastClose !== -1) {
return content.slice(0, lastClose) + block + "\n" + content.slice(lastClose);
}
return content + "\n" + block + "\n";
}
// ── CRUD route builders ─────────────────────────────────────────────────────
// Field names that must never be mass-assigned from a request body — privilege
// flags and prototype-pollution keys. Excluded from generated create/update DTOs
// by default; set these server-side (e.g. in an admin route) instead.
export const MASS_ASSIGN_BLOCKED = new Set([
"role", "roles", "isadmin", "admin", "isadministrator",
"verified", "isverified", "permissions", "scopes",
"__proto__", "constructor", "prototype",
]);
function writableFields(fields) {
return fields.filter((f) => !MASS_ASSIGN_BLOCKED.has(String(f).toLowerCase()));
}
export function buildInsertCode(pascalName, fields, dataSource) {
fields = writableFields(fields);
const assignments = fields
.map((f) => ` ${f}: ${dataSource}.${f}`)
.join(",\n");
let passwordLogic = "";
if (fields.includes("password")) {
passwordLogic = `
if (newData.password) {
const salt = await bcrypt.genSalt(10);
newData.password = await bcrypt.hash(newData.password, salt);
}`;
}
return `
router.post('/create', async (req, res) => {
const newData = {
${assignments}
};
${passwordLogic}
try {
const newDocument = new ${pascalName}(newData);
await newDocument.save();
res.json({ message: 'Data inserted successfully', data: newDocument });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Server error' });
}
});`;
}
export function buildReadCode(pascalName) {
return `
router.get('/', async (req, res) => {
try {
// Paginate to avoid returning an unbounded collection. ?page & ?limit (max 100).
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
const skip = (page - 1) * limit;
const [documents, total] = await Promise.all([
${pascalName}.find().select('-password').skip(skip).limit(limit),
${pascalName}.countDocuments(),
]);
res.json({ data: documents, page, limit, total, totalPages: Math.ceil(total / limit) });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Server error' });
}
});
router.get('/:id', async (req, res) => {
try {
// .select('-password') never leaks credential hashes even if the model has one.
const document = await ${pascalName}.findById(req.params.id).select('-password');
if (!document) {
return res.status(404).json({ message: 'Document not found' });
}
res.json({ data: document });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Server error' });
}
});`;
}
export function buildUpdateCode(pascalName, fields, dataSource) {
fields = writableFields(fields);
const assignments = fields
.map((f) => ` ${f}: ${dataSource}.${f}`)
.join(",\n");
let passwordLogic = "";
if (fields.includes("password")) {
passwordLogic = `
if (updatedData.password) {
const salt = await bcrypt.genSalt(10);
updatedData.password = await bcrypt.hash(updatedData.password, salt);
}`;
}
return `
router.put('/:id', async (req, res) => {
const updatedData = {
${assignments}
};
${passwordLogic}
try {
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
if (!updatedDocument) {
return res.status(404).json({ message: 'Document not found' });
}
res.json({ message: 'Data updated successfully', data: updatedDocument });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Server error' });
}
});`;
}
export function buildDeleteCode(pascalName) {
return `
router.delete('/:id', async (req, res) => {
try {
const deletedDocument = await ${pascalName}.findByIdAndDelete(req.params.id);
if (!deletedDocument) {
return res.status(404).json({ message: 'Document not found' });
}
res.json({ message: 'Data deleted successfully', data: deletedDocument });
} catch (err) {
console.error(err);
res.status(500).json({ message: 'Server error' });
}
});`;
}
// ── Mongoose model generation ───────────────────────────────────────────────
// Detailed generator supporting type, ref, required, unique, index, default,
// and compound/text indexes. `pascalName` is used for both the schema variable
// and the model name.
export function generateMongooseModel(pascalName, fields, indexes = []) {
const fieldLines = fields.map((f) => {
const type = f.type || "String";
const parts = [
type === "ObjectId"
? "type: mongoose.Schema.Types.ObjectId"
: `type: ${type}`,
];
if (f.ref) parts.push(`ref: '${f.ref}'`);
if (f.required) parts.push("required: true");
if (f.unique) parts.push("unique: true");
if (f.index) parts.push("index: true");
if (f.default !== undefined && f.default !== "")
parts.push(`default: ${f.default}`);
// Never return credential fields by default — read queries must opt in with
// .select('+password'). Prevents accidental hash leaks through find()/findById.
if (/^password$/i.test(f.name) && f.select === undefined)
parts.push("select: false");
return ` ${f.name}: { ${parts.join(", ")} }`;
});
let indexLines = "";
for (const idx of indexes) {
const fieldObj = idx.fields
.map((f) => {
if (f.direction === "text") return `${f.field}: 'text'`;
return `${f.field}: ${f.direction || 1}`;
})
.join(", ");
const options = idx.unique ? ", { unique: true }" : "";
indexLines += `\n${pascalName}Schema.index({ ${fieldObj} }${options});`;
}
return `import mongoose from 'mongoose';
const ${pascalName}Schema = new mongoose.Schema({
${fieldLines.join(",\n")}
}, { timestamps: true });
${indexLines}
const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
export default ${pascalName};
`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 1 — Security & Middleware
//
// File-content builders for generated middleware. Index.js wiring (imports +
// app.use placement) is handled by the caller via `securityWiring()`.
// ─────────────────────────────────────────────────────────────────────────────
// src/middleware/errorHandler.js — central 404 + error handler.
export function buildErrorHandlerFile() {
return `// Central error handling. Register AFTER all routes:
// app.use(notFound);
// app.use(errorHandler);
export function notFound(req, res, next) {
res.status(404).json({ message: \`Not found - \${req.originalUrl}\` });
}
export function errorHandler(err, req, res, next) {
const status = err.status || err.statusCode || 500;
console.error(err);
const isProd = process.env.NODE_ENV === 'production';
// In production, only surface messages for client (4xx) errors or errors
// explicitly marked safe (err.expose). Never leak raw 5xx internals — a Mongo
// E11000 or driver message discloses schema/indexes and enables enumeration.
const showMessage = !isProd || err.expose === true || (status >= 400 && status < 500);
res.status(status).json({
message: showMessage ? (err.message || 'Server error') : 'Server error',
...(isProd ? {} : { stack: err.stack }),
});
}
`;
}
// src/middleware/roles.js — role-based access control (RBAC).
export function buildRolesGuardFile() {
return `// Role-based access control. Use AFTER the JWT auth middleware so req.user is set:
// router.get('/admin', auth, roles('admin'), handler);
// Requires the login token payload to include a \`role\` (e.g. jwt.sign({ id, role })).
const roles = (...allowed) => (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
if (allowed.length && !allowed.includes(req.user.role)) {
return res.status(403).json({ message: 'Forbidden: insufficient role' });
}
next();
};
export default roles;
`;
}
// src/middleware/auth.js — JWT verification. Reads a Bearer token, verifies it
// with JWT_SECRET, and sets req.user. Use as middleware: router.get('/', auth, h).
export function buildAuthMiddleware() {
return `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 so a token can't be forged via alg-confusion (e.g. an
// attacker signing HS256 with a public key if RS256 is ever introduced).
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;
`;
}
// src/middleware/validate.js — Zod request validation middleware.
export function buildValidateFile() {
return `import { ZodError } from 'zod';
// Validate part of the request against a Zod schema. Replaces req[source] with the
// parsed/coerced data on success. Use as middleware:
// router.post('/', validate(userSchema), handler); // validates req.body
// router.get('/', validate(querySchema, 'query'), handler); // validates req.query
const validate = (schema, source = 'body') => (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
const errors = (result.error instanceof ZodError ? result.error.issues : []).map((i) => ({
path: i.path.join('.'),
message: i.message,
}));
return res.status(400).json({ message: 'Validation failed', errors });
}
req[source] = result.data;
next();
};
export default validate;
`;
}
// Map a Mongoose-style field descriptor to a Zod expression (no trailing optionality).
function zodForField(field) {
const t = field.type || "String";
switch (t) {
case "Number":
return "z.coerce.number()";
case "Boolean":
return "z.coerce.boolean()";
case "Date":
return "z.coerce.date()";
case "ObjectId":
return "z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid id')";
case "Array":
return "z.array(z.any())";
case "Buffer":
case "Mixed":
return "z.any()";
case "String":
default:
return "z.string()";
}
}
// src/validators/<name>.js — a Zod schema derived from a model's fields.
export function buildZodSchemaFile(name, fields) {
const camel = toCamelCase(name);
const lines = fields.map((f) => {
let expr = zodForField(f);
if (!f.required) expr += ".optional()";
return ` ${f.name}: ${expr},`;
});
return `import { z } from 'zod';
export const ${camel}Schema = z.object({
${lines.join("\n")}
});
export default ${camel}Schema;
`;
}
// Parse a Zod validator's source into a { fieldName: isRequired } map for the
// top-level fields of its `z.object({ ... })`. A field is "required" for the API
// when its Zod chain has no `.optional()`/`.nullish()` — i.e. the request is
// rejected without it. This is the right source of truth for OpenAPI `required`,
// because the validator (not the Mongoose model) is what actually gates a request:
// a field can be optional in the DB schema yet required by the API, or vice versa.
// Returns null if no `z.object({...})` is found (caller should fall back to the model).
export function requiredFieldsFromZod(src) {
if (!src || typeof src !== "string") return null;
const objIdx = src.indexOf("z.object(");
if (objIdx === -1) return null;
const braceStart = src.indexOf("{", objIdx);
if (braceStart === -1) return null;
// Walk balanced brackets so nested objects/arrays/calls don't end the block early.
let depth = 0;
let end = -1;
for (let i = braceStart; i < src.length; i++) {
const c = src[i];
if (c === "{" || c === "(" || c === "[") depth++;
else if (c === "}" || c === ")" || c === "]") {
depth--;
if (depth === 0 && c === "}") { end = i; break; }
}
}
if (end === -1) return null;
const inner = src.slice(braceStart + 1, end);
const map = {};
const flush = (entry) => {
const colon = entry.indexOf(":");
if (colon === -1) return;
const key = entry.slice(0, colon).trim().replace(/^['"]|['"]$/g, "");
if (!/^[A-Za-z_$][\w$]*$/.test(key)) return;
const expr = entry.slice(colon + 1);
// .nullable() still requires the key (value may be null), so it doesn't count.
map[key] = !/\.(optional|nullish)\s*\(\s*\)/.test(expr);
};
// Split top-level "key: expr" entries on depth-0 commas.
let d = 0;
let cur = "";
for (let i = 0; i < inner.length; i++) {
const c = inner[i];
if (c === "{" || c === "(" || c === "[") d++;
else if (c === "}" || c === ")" || c === "]") d--;
if (c === "," && d === 0) { flush(cur); cur = ""; }
else cur += c;
}
if (cur.trim()) flush(cur);
return Object.keys(map).length ? map : null;
}
// Wire `validate(<model>Schema)` into the write endpoints of a route file, and add
// the two needed imports. POST/PUT use the full schema; PATCH uses
// `<schema>.partial()` so partial updates aren't forced to send every required
// field. GET/DELETE are left alone (no body). Idempotent: skips endpoints that
// already have a validate(...) middleware. `modelBase` is the validator file base
// name (e.g. "user" → ../validators/user.js, userSchema). Returns the content
// unchanged when there are no write endpoints to wire.
export function wireValidateIntoRouteContent(content, modelBase) {
const schemaVar = toCamelCase(modelBase) + "Schema";
// Capture: (router.<method>('/...', <any existing middleware>, )(method)(handler arrow)
const handlerRe =
/(router\.(post|put|patch)\([^\n]*?,\s*)((?:async\s*)?\(\s*req\s*,\s*res\s*(?:,\s*next\s*)?\)\s*=>)/g;
let changed = false;
const replaced = content.replace(handlerRe, (full, head, method, handler) => {
if (head.includes("validate(")) return full; // already guarded
const expr = method === "patch" ? `${schemaVar}.partial()` : schemaVar;
changed = true;
return `${head}validate(${expr}), ${handler}`;
});
if (!changed) return content;
let out = addImportToContent(replaced, `import validate from '../middleware/validate.js';`);
out = addImportToContent(out, `import { ${schemaVar} } from '../validators/${modelBase}.js';`);
return out;
}
// Protect a whole route file with JWT: insert `auth` as the FIRST middleware on
// every endpoint (so it runs before validation), and add the import. Idempotent.
export function wireAuthIntoRouteContent(content) {
// (router.<method>('/path', )(existing middleware)(handler arrow)
const handlerRe =
/(router\.(?:get|post|put|patch|delete)\(\s*['"`][^'"`]*['"`]\s*,\s*)([\s\S]*?)((?:async\s*)?\(\s*req\s*,\s*res\s*(?:,\s*next\s*)?\)\s*=>)/g;
let changed = false;
const replaced = content.replace(handlerRe, (full, pathPart, middleware, handler) => {
if (/\bauth\s*,/.test(middleware)) return full; // already protected
changed = true;
return `${pathPart}auth, ${middleware}${handler}`;
});
if (!changed) return content;
return addImportToContent(replaced, "import auth from '../middleware/auth.js';");
}
// Anchor comment that marks the central error handler in index.js. Route
// registration looks for this so new routes are always inserted ABOVE it.
export const ERROR_HANDLER_MARKER = "// ── Error handling (must be last) ──";
// Index.js wiring plan for the selected app-level protections. Returns the imports
// to add and the code blocks to insert before routes / before the listen call.
// `features` is a set of: 'helmet', 'rateLimit', 'errorHandler'.
export function securityWiring(features) {
const has = (f) => features.includes(f);
const imports = [];
const beforeRoutesParts = [];
let beforeListen = "";
if (has("helmet")) {
imports.push("import helmet from 'helmet';");
beforeRoutesParts.push(
`// ── Security headers ──
// helmet() defaults to a strict Content-Security-Policy (script-src 'self',
// etc.) that blocks CDN-loaded scripts, inline <script> blocks, and cross-origin
// assets. We widen the policy so a typical frontend/docs page works while keeping
// the rest of helmet's protections. Tighten these directives for production.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https:', "'unsafe-inline'"],
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
// Allow other origins (e.g. a separate frontend host) to load assets served
// by this app. Default is 'same-origin'. Tighten to 'same-site' for production.
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));`
);
}
if (has("rateLimit")) {
imports.push("import rateLimit from 'express-rate-limit';");
beforeRoutesParts.push(
`// ── Rate limiting ──
// Generous global cap — high enough that real frontends (which fire many calls
// per page) never notice, low enough to stop scraping/abuse at scale.
// Window + cap are read from .env (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX) so the
// dashboard's Security page can tune them; fall back to 15 min / 500 requests.
const apiLimiter = rateLimit({
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: Number(process.env.RATE_LIMIT_MAX) || 500,
standardHeaders: true,
legacyHeaders: false,
});
// Strict cap for authentication routes — defends against password brute-forcing.
// Point this at your real auth routes (login / signup / password-reset).
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per IP per window
standardHeaders: true,
legacyHeaders: false,
});
// Throttle everywhere EXCEPT explicit local development. Defaulting to on means a
// deploy that forgets to set NODE_ENV=production is still protected (an unset
// NODE_ENV must never mean "no rate limiting" — that was the /_dev breach posture).
if (process.env.NODE_ENV !== 'development') {
app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
app.use(apiLimiter); // generous global limiter for everything else
}`
);
}
if (has("errorHandler")) {
imports.push(
"import { notFound, errorHandler } from './src/middleware/errorHandler.js';"
);
beforeListen = `${ERROR_HANDLER_MARKER}\napp.use(notFound);\napp.use(errorHandler);`;
}
return { imports, beforeRoutes: beforeRoutesParts.join("\n\n"), beforeListen };
}
// ── Pure index.js string transforms ─────────────────────────────────────────
// Used by both the CLI (lib/indexFile.js) and the in-project dashboard
// (dev-api.js) so the insertion logic lives in exactly one place.
// Add an import line after the last existing import. Idempotent.
export function addImportAfterLastImport(content, importLine) {
const line = importLine.trim();
if (content.includes(line)) return content;
const lines = content.split("\n");
let lastImportIdx = -1;
lines.forEach((l, i) => {
if (l.startsWith("import ")) lastImportIdx = i;
});
lines.splice(lastImportIdx + 1, 0, line);
return lines.join("\n");
}
// Insert a code block just before the server starts listening. If the central
// error handler is present its marker takes precedence (new code stays above it,
// the handler stays last). Otherwise anchor on the listen/start CALL — matched by
// .index so the bare token inside `async function startServer(...)` is never hit.
export function insertBeforeListenContent(content, codeBlock) {
const block = codeBlock.trim();
const markerIdx = content.indexOf(ERROR_HANDLER_MARKER);
let at;
if (markerIdx !== -1) {
at = markerIdx;
} else {
const m =
content.match(/(app|server)\.listen\(port/) || content.match(/^startServer\(/m);
if (!m) return content + "\n" + block + "\n";
at = m.index;
}
return content.slice(0, at) + block + "\n\n" + content.slice(at);
}
// Insert app-level middleware (e.g. helmet) ahead of the route/start section so it
// wraps all subsequently-registered routes.
export function insertBeforeRoutesContent(content, codeBlock) {
const block = codeBlock.trim();
const markers = ["// ── Start Server", "// ── Helpers", "\nstartServer("];
let idx = -1;
for (const marker of markers) {
idx = content.indexOf(marker);
if (idx !== -1) break;
}
if (idx === -1) {
const m = content.match(/^startServer\(/m);
idx = m ? m.index : content.length;
}
return content.slice(0, idx) + block + "\n\n" + content.slice(idx);
}
// ─────────────────────────────────────────────────────────────────────────────
// Phase 2 — AI Builder
//
// The AI never emits raw code. It emits a structured PLAN (models + CRUD routes)
// that these deterministic builders turn into files — so AI output is held to the
// same shape and safety as hand-driven generation.
// ─────────────────────────────────────────────────────────────────────────────
export const AI_FIELD_TYPES = [
"String", "Number", "Boolean", "Date", "ObjectId", "Array", "Mixed", "Buffer",
];
export const AI_OPERATIONS = ["create", "read", "update", "delete"];
// Supported AI providers. Each plan is requested with a forced tool/function call
// for structured output, so the same AI_PLAN_SCHEMA works across providers.
export const AI_PROVIDERS = {
openai: {
id: "openai",
label: "OpenAI",
keyEnv: "OPENAI_API_KEY",
defaultModel: "gpt-4o-mini",
keyPlaceholder: "sk-...",
keysUrl: "platform.openai.com",
},
anthropic: {
id: "anthropic",
label: "Anthropic (Claude)",
keyEnv: "ANTHROPIC_API_KEY",
defaultModel: "claude-sonnet-4-6",
keyPlaceholder: "sk-ant-...",
keysUrl: "console.anthropic.com",
},
};
export const DEFAULT_AI_PROVIDER = "openai";
// JSON Schema handed to Claude as a forced tool input — guarantees structured output.
export const AI_PLAN_SCHEMA = {
type: "object",
properties: {
summary: { type: "string", description: "One sentence describing what will be built." },
models: {
type: "array",
description: "Mongoose models to create.",
items: {
type: "object",
properties: {
name: { type: "string", description: "Singular lowercase model name, e.g. 'post'." },
fields: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
type: { type: "string", enum: AI_FIELD_TYPES },
required: { type: "boolean" },
unique: { type: "boolean" },
ref: { type: "string", description: "Referenced model name (PascalCase) when type is ObjectId." },
},
required: ["name", "type"],
},
},
},
required: ["name", "fields"],
},
},
routes: {
type: "array",
description: "CRUD API routes to create, each bound to a model.",
items: {
type: "object",
properties: {
name: { type: "string", description: "Route name, usually plural, e.g. 'posts'." },
model: { type: "string", description: "The model name this route operates on." },
operations: { type: "array", items: { type: "string", enum: AI_OPERATIONS } },
},
required: ["name", "model", "operations"],
},
},
},
required: ["summary", "models", "routes"],
};
export const AI_SYSTEM_PROMPT = `You are a backend architect for a Node.js + Express + Mongoose application.
Turn the user's request into a concrete plan of Mongoose models and CRUD API routes.
Rules:
- Respond ONLY by calling the emit_plan tool. Do not write prose or code.
- Model names are singular and lowercase (e.g. "post", "user").
- Route names are usually the plural of their model (e.g. "posts").
- Allowed field types: ${AI_FIELD_TYPES.join(", ")}.
- For relations between models, use type "ObjectId" with "ref" set to the related model's PascalCase name.
- Include a "password" field (type String) only when authentication is clearly intended; it will be auto-hashed.
- Every route must reference one of the models in the plan and choose from operations: ${AI_OPERATIONS.join(", ")}.
- Keep the plan minimal and faithful to the request; do not invent unrelated models.`;
// Coerce an arbitrary string into a safe JS identifier name, or null if impossible.
function safeIdentifier(name) {
if (typeof name !== "string") return null;
const cleaned = name.trim().replace(/[^a-zA-Z0-9_-]/g, "");
if (!cleaned || !/^[a-zA-Z]/.test(cleaned)) return null;
return cleaned;
}
// Validate and clean a raw plan from the model into a safe, de-duplicated plan.
// Defensive: bad models/fields/routes are dropped rather than trusted.
export function normalizeAiPlan(plan) {
const out = {
summary: typeof plan?.summary === "string" ? plan.summary : "",
models: [],
routes: [],
};
const modelNames = new Set();
for (const m of Array.isArray(plan?.models) ? plan.models : []) {
const ident = safeIdentifier(m?.name);
if (!ident) continue;
const name = toCamelCase(ident);
if (modelNames.has(name)) continue;
const fields = [];
const seen = new Set();
for (const f of Array.isArray(m?.fields) ? m.fields : []) {
const fIdent = safeIdentifier(f?.name);
if (!fIdent || seen.has(fIdent)) continue;
const type = AI_FIELD_TYPES.includes(f?.type) ? f.type : "String";
const field = { name: fIdent, type };
if (f?.required) field.required = true;
if (f?.unique) field.unique = true;
const refIdent = type === "ObjectId" ? safeIdentifier(f?.ref) : null;
if (refIdent) field.ref = toPascalCase(refIdent);
seen.add(fIdent);
fields.push(field);
}
if (fields.length === 0) continue;
modelNames.add(name);
out.models.push({ name, fields });
}
// Map a model's lowercased name to its canonical name so a route that
// references the model with different capitalization still resolves to the
// exact file that was written. Without this, the model file (model.name) and
// the route import path (route.model) can diverge only in first-letter case —
// harmless on case-insensitive macOS, but a "Cannot find module" crash on a
// case-sensitive Linux production server.
const modelByKey = new Map(out.models.map((m) => [m.name.toLowerCase(), m.name]));
const routeNames = new Set();
for (const r of Array.isArray(plan?.routes) ? plan.routes : []) {
const ident = safeIdentifier(r?.name);
const model = safeIdentifier(r?.model);
if (!ident || !model) continue;
const name = toCamelCase(ident);
if (routeNames.has(name)) continue;
let operations = (Array.isArray(r?.operations) ? r.operations : []).filter((o) =>
AI_OPERATIONS.includes(o)
);
if (operations.length === 0) operations = [...AI_OPERATIONS];
routeNames.add(name);
const modelCamel = toCamelCase(model);
const resolvedModel = modelByKey.get(modelCamel.toLowerCase()) || modelCamel;
out.routes.push({ name, model: resolvedModel, operations });
}
return out;
}
// Build a complete CRUD route file (imports + router + selected operations + export)
// for a model, reusing the same builders as `add crud`.
export function buildCrudRouteFile(routeName, modelName, fieldNames = [], operations = AI_OPERATIONS) {
const pascal = toPascalCase(modelName);
const needsBcrypt =
fieldNames.includes("password") &&
(operations.includes("create") || operations.includes("update"));
const importLines = ["import express from 'express';"];
if (needsBcrypt) importLines.push("import bcrypt from 'bcrypt';");
importLines.push(`import ${pascal} from '../models/${modelName}.js';`);
const blocks = [];
if (operations.includes("create")) blocks.push(buildInsertCode(pascal, fieldNames, "req.body"));
if (operations.includes("read")) blocks.push(buildReadCode(pascal));
if (operations.includes("update")) blocks.push(buildUpdateCode(pascal, fieldNames, "req.body"));
if (operations.includes("delete")) blocks.push(buildDeleteCode(pascal));
return `${importLines.join("\n")}
const router = express.Router();
${blocks.join("\n")}
export default router;
`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Dependency bookkeeping — record packages in package.json so the generated app
// is always installable, even when a live `npm install` is interrupted/offline.
// ─────────────────────────────────────────────────────────────────────────────
// Known versions for packages 4bnode wires into generated apps.
export const PACKAGE_VERSIONS = {
helmet: "^8.0.0",
"express-rate-limit": "^7.4.0",
zod: "^3.23.0",
bcrypt: "^5.1.1",
jsonwebtoken: "^9.0.2",
"socket.io": "^4.8.1",
ws: "^8.18.0",
serialport: "^12.0.0",
"@serialport/parser-readline": "^12.0.0",
mongoose: "^8.8.0",
multer: "^1.4.5-lts.1",
nodemailer: "^6.9.0",
resend: "^4.0.0",
"bonjour-service": "^1.4.3",
};
// Add packages to package.json (dependencies, or devDependencies when dev:true).
// Pure: takes the file CONTENT, returns { content, changed }. Skips names already
// present in either deps or devDeps. Unknown packages fall back to "latest".
export function addDepsToPackageJson(content, names, { dev = false } = {}) {
const pkg = JSON.parse(content);
const key = dev ? "devDependencies" : "dependencies";
pkg[key] = pkg[key] || {};
let changed = false;
for (const name of names) {
const inDeps = pkg.dependencies && pkg.dependencies[name];
const inDev = pkg.devDependencies && pkg.devDependencies[name];
if (!inDeps && !inDev) {
pkg[key][name] = PACKAGE_VERSIONS[name] || "latest";
changed = true;
}
}
return { content: JSON.stringify(pkg, null, 2) + "\n", changed };
}
// ─────────────────────────────────────────────────────────────────────────────
// Request-field detection — used by the API Tester to auto-fill keys from a route.
// Handles BOTH destructuring (`const { a, b } = req.body`) AND direct member access
// (`req.body.a`, `req.body['a']`), since generated CRUD routes use the latter.
// ─────────────────────────────────────────────────────────────────────────────
export function extractRequestFields(handlerBody, source = "body") {
const src = source === "query" ? "query" : "body";
const names = [];
const seen = new Set();
const add = (raw) => {
const n = (raw || "").trim();
if (n && /^[A-Za-z_$][\w$]*$/.test(n) && !seen.has(n)) {
seen.add(n);
names.push(n);
}
};
const body = handlerBody || "";
// const { a, b: x } = req.<src> → take the key before any ':'
const destructure = body.match(new RegExp(`const\\s*\\{\\s*([^}]+)\\}\\s*=\\s*req\\.${src}`));
if (destructure) destructure[1].split(",").forEach((f) => add(f.split(":")[0]));
// req.<src>.field
const dot = new RegExp(`req\\.${src}\\.(\\w+)`, "g");
let m;
while ((m = dot.exec(body)) !== null) add(m[1]);
// req.<src>['field'] / req.<src>["field"]
const bracket = new RegExp(`req\\.${src}\\[\\s*['"](\\w+)['"]\\s*\\]`, "g");
while ((m = bracket.exec(body)) !== null) add(m[1]);
return names;
}
// Detect multer upload fields in a route handler so the OpenAPI builder can mark
// the body as multipart/form-data with the right file fields. Handles:
// upload.single('avatar') -> [{name:'avatar'}]
// upload.array('photos', 5) -> [{name:'photos'}]
// upload.fields([{ name: 'a' }, { name: 'b' }]) -> [{name:'a'},{name:'b'}]
export function extractFileFields(handlerBody) {
const body = handlerBody || "";
const out = [];
const seen = new Set();
const add = (name) => { if (name && !seen.has(name)) { seen.add(name); out.push({ name }); } };
(body.match(/upload\.(?:single|array)\(\s*['"]([^'"]+)['"]/g) || []).forEach((s) => {
const m = s.match(/['"]([^'"]+)['"]/);
if (m) add(m[1]);
});
const fieldsCall = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]/);
if (fieldsCall) {
(fieldsCall[1].match(/name\s*:\s*['"]([^'"]+)['"]/g) || []).forEach((n) => {
const m = n.match(/['"]([^'"]+)['"]/);
if (m) add(m[1]);
});
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
// Reuses extractRequestFields output (endpoints) and model field metadata.
// ─────────────────────────────────────────────────────────────────────────────
function openApiType(t) {
switch (t) {
case "Number":
case "Int": return { type: "integer" };
case "Float": return { type: "number" };
case "Boolean": return { type: "boolean" };
case "Date":
case "DateTime": return { type: "string", format: "date-time" };
case "ObjectId": return { type: "string" };
case "Array": return { type: "array", items: {} };
case "Buffer": return { type: "string", format: "binary" };
case "Json":
case "Mixed": return { type: "object" };
case "String":
default: return { type: "string" };
}
}
// Build an OpenAPI 3.0 document from parsed endpoints + models.
// endpoints: [{ method, path, fullPath, bodyFields[], queryFields[], params[], fileFields[], hasAuth }]
// models: [{ name, fields:[{name,type,required}] }]
export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints = [], models = [] } = {}) {
const paths = {};
for (const ep of endpoints) {
const raw = ep.fullPath || ep.path || "/";
const oaPath = raw.replace(/:(\w+)/g, "{$1}");
const method = (ep.method || "GET").toLowerCase();
paths[oaPath] = paths[oaPath] || {};
const op = { summary: `${ep.method} ${raw}`, responses: { "200": { description: "Success" } } };
const parameters = [];
(ep.params || []).forEach((p) => parameters.push({ name: p, in: "path", required: true, schema: { type: "string" } }));
(ep.queryFields || []).forEach((f) => parameters.push({ name: f.name, in: "query", required: false, schema: openApiType(f.type) }));
if (parameters.length) op.parameters = parameters;
const bodyFields = ep.bodyFields || [];
const fileFields = ep.fileFields || [];
if (["post", "put", "patch"].includes(method) && (bodyFields.length || fileFields.length)) {
const properties = {};
const required = [];
bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
// Uploaded files are required by default (an upload endpoint exists to receive
// the file). A file field can opt out with required:false.
fileFields.forEach((f) => { properties[f.name] = { type: "string", format: "binary" }; if (f.required !== false) required.push(f.name); });
const schema = { type: "object", properties };
if (required.length) schema.required = required;
const mime = fileFields.length ? "multipart/form-data" : "application/json";
op.requestBody = { content: { [mime]: { schema } } };
}
if (ep.hasAuth) op.security = [{ bearerAuth: [] }];
paths[oaPath][method] = op;
}
const schemas = {};
for (const m of models) {
const properties = {};
(m.fields || []).forEach((f) => { properties[f.name] = openApiType(f.type); });
schemas[toPascalCase(m.name)] = { type: "object", properties };
}
const components = { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } };
if (Object.keys(schemas).length) components.schemas = schemas;
return { openapi: "3.0.0", info: { title, version }, servers: [{ url: "/" }], paths, components };
}
// A self-contained, modern API documentation page rendered from the spec.
// No Swagger, no external CDN, no third-party branding — pure 4bnode dark style.
// Two-pane layout: endpoint list on the left, selected endpoint details + an
// inline "Try it" client on the right. Fetches the spec at runtime.
export function buildDocsHtml(specUrl = "openapi.json") {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>API Documentation</title>
<style>
* { box-sizing: border-box; }
:root {
--bg:#0f1117; --surface:#11141e; --card:#1a1d2b; --card-hover:#21253a; --input:#13161f;
--border:rgba(255,255,255,0.08); --text:#eceef4; --muted:#8b8fa3; --faint:#626880;
--accent:#0079ff; --accent-light:#3daefd; --accent-glow:rgba(0,121,255,0.14);
}
* { scrollbar-width: thin; }
html, body { margin:0; padding:0; height:100%; }
body { background:var(--bg); color:var(--text); line-height:1.5; height:100vh;
display:flex; flex-direction:column; overflow:hidden;
font-family:"Noto Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
.topbar { flex:none; display:flex; align-items:center; gap:14px;
background:var(--surface); border-bottom:1px solid var(--border); padding:14px 24px; }
.topbar h1 { font-size:17px; font-weight:700; margin:0;
background:linear-gradient(135deg,#3daefd,#0079ff); -webkit-background-clip:text;
background-clip:text; -webkit-text-fill-color:transparent; }
.pill-ver { font-size:12px; color:var(--accent-light); background:rgba(0,121,255,0.12);
border:1px solid rgba(0,121,255,0.25); padding:3px 9px; border-radius:999px; }
.layout { flex:1; display:flex; min-height:0; }
.sidebar { width:330px; flex:none; border-right:1px solid var(--border);
overflow-y:auto; padding:14px 12px; }
.search { width:100%; background:var(--input); border:1px solid var(--border);
color:var(--text); border-radius:9px; padding:8px 12px; font-size:13px; outline:none; margin-bottom:12px; }
.search:focus { border-color:var(--accent); }
.group-title { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px;
color:var(--faint); margin:14px 6px 6px; }
.nav-item { display:flex; align-items:center; gap:9px; padding:8px 10px; border-radius:8px;
cursor:pointer; border:1px solid transparent; }
.nav-item:hover { background:var(--card-hover); }
.nav-item.active { background:var(--accent-glow); border-color:rgba(0,121,255,0.35); }
.nav-method { font-size:9.5px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase;
color:#fff; padding:3px 0; border-radius:5px; width:46px; flex:none; text-align:center; }
.nav-path { font-family:ui-monospace,Menlo,monospace; font-size:12.5px; color:var(--text);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.main { flex:1; overflow-y:auto; padding:30px 40px; }
.main-empty { color:var(--faint); display:flex; height:100%; align-items:center; justify-content:center; }
.detail-head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
.method-lg { font-size:13px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase;
color:#fff; padding:6px 12px; border-radius:8px; }
.path-lg { font-family:ui-monospace,Menlo,monospace; font-size:18px; color:var(--text); word-break:break-all; }
.detail-sum { color:var(--muted); margin:0 0 8px; }
.lock { color:var(--faint); font-size:12px; margin-left:8px; white-space:nowrap; }
.sec { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px;
color:var(--faint); margin:22px 0 8px; }
.row { display:flex; gap:10px; align-items:center; padding:8px 0;
border-bottom:1px solid var(--border); font-size:13px; }
.row:last-child { border-bottom:none; }
.fname { font-family:ui-monospace,Menlo,monospace; color:var(--text); min-width:150px; }
.ftype { color:var(--accent-light); font-size:12px; }
.req { color:#ef4444; font-size:11px; font-weight:700; }
.opt { color:var(--faint); font-size:11px; }
.resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
.tabs { display:flex; gap:4px; flex-wrap:wrap; }
.tab { background:var(--input); color:var(--muted); border:1px solid var(--border); border-bottom:none;
border-radius:8px 8px 0 0; padding:6px 13px; font-size:12px; cursor:pointer; }
.tab:hover { color:var(--text); }
.tab.active { background:#0a0c12; color:var(--accent-light); }
.code-wrap { position:relative; max-width:560px; }
pre.code { background:#0a0c12; border:1px solid var(--border); border-radius:0 8px 8px 8px; padding:14px;
font-size:12.5px; color:#d5d8e3; overflow:auto; white-space:pre; margin:0;
font-family:ui-monospace,Menlo,monospace; }
pre.code.hidden { display:none; }
.copy-btn { position:absolute; top:8px; right:8px; background:var(--card); color:var(--muted);
border:1px solid var(--border); border-radius:6px; padding:4px 10px; font-size:11px; cursor:pointer; z-index:2; }
.copy-btn:hover { color:var(--text); }
::-webkit-scrollbar { width:9px; height:9px; }
::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
</style>
</head>
<body>
<div class="topbar">
<h1 id="apiTitle">API</h1>
<span class="pill-ver" id="apiVersion">v1.0.0</span>
</div>
<div class="layout">
<aside class="sidebar">
<input class="search" id="search" placeholder="Filter endpoints..." />
<div id="nav"></div>
</aside>
<main class="main" id="main"><div class="main-empty">Select an endpoint to view its details.</div></main>
</div>
<script>
(function(){
var SPEC_URL='${specUrl}';
var COLORS={get:'#0079ff',post:'#22c55e',put:'#f59e0b',patch:'#a855f7',delete:'#ef4444',head:'#64748b',options:'#64748b'};
var uid=0; var ITEMS=[];
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function typeName(sc){if(!sc)return'';if(sc.format==='binary')return'file';if(sc.$ref)return sc.$ref.split('/').pop();return sc.type||'string';}
function paramsOf(op,where){return (op.parameters||[]).filter(function(p){return p.in===where;});}
function bodyOf(op){if(!op.requestBody)return null;var c=op.requestBody.content||{};var mime=Object.keys(c)[0];if(!mime)return null;return {mime:mime,schema:(c[mime]||{}).schema||{}};}
function sampleVal(sc){var t=typeName(sc);if(t==='integer'||t==='number')return 0;if(t==='boolean')return false;return'';}
function bodySkeleton(schema){var o={};var p=(schema&&schema.properties)||{};Object.keys(p).forEach(function(k){o[k]=sampleVal(p[k]);});return JSON.stringify(o,null,2);}
function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
function collect(spec){var out=[];var paths=spec.paths||{};Object.keys(paths).forEach(function(p){var item=paths[p];Object.keys(item).forEach(function(m){out.push({path:p,method:m,op:item[m]});});});return out;}
// ── Client code samples (generated from the spec — no AI, no network) ──
function sampleUrl(e){return (location.origin||'')+e.path;}
function bodyForSample(e){var b=bodyOf(e.op);if(b&&['post','put','patch'].indexOf(e.method)>=0)return b;return null;}
function indentBody(str,extra){var NL=String.fromCharCode(10);return str.split(NL).map(function(ln,i){return i===0?ln:extra+ln;}).join(NL);}
function isMultipart(b){return !!(b&&b.mime==='multipart/form-data');}
// Body fields with a flag for binary (file) fields, used to build multipart samples.
function fieldList(b){var p=(b&&b.schema&&b.schema.properties)||{};return Object.keys(p).map(function(k){return {name:k,file:!!(p[k]&&p[k].format==='binary')};});}
// FormData lines for a multipart body: files become a File object, others a string.
function formLines(b){
var out=["const form = new FormData();"];
fieldList(b).forEach(function(f){
out.push("form.append('"+f.name+"', "+(f.file?"fileInput.files[0]":"''")+");"+(f.file?" // a File from an <input type=file>":""));
});
return out;
}
function curlSample(e){
var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];
L.push("curl -X "+e.method.toUpperCase()+" '"+sampleUrl(e)+"'");
if(e.op.security)L.push("-H 'Authorization: Bearer <token>'");
if(b&&isMultipart(b)){
// curl sets the multipart Content-Type (with boundary) itself; -F per field.
fieldList(b).forEach(function(f){L.push("-F '"+f.name+"="+(f.file?"@/path/to/file":"")+"'");});
} else if(b){
L.push("-H 'Content-Type: application/json'");
var c;try{c=JSON.stringify(JSON.parse(bodySkeleton(b.schema)));}catch(x){c=bodySkeleton(b.schema);}
L.push("-d '"+c+"'");
}
return L.join(" "+BS+NL+" ");
}
function fetchSample(e){
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
L.push("const res = await fetch('"+sampleUrl(e)+"', {");
L.push(" method: '"+e.method.toUpperCase()+"',");
var h=[];
// For multipart, never set Content-Type by hand — the browser adds the boundary.
if(b&&!mp)h.push(" 'Content-Type': 'application/json'");
if(e.op.security)h.push(" 'Authorization': 'Bearer <token>'");
if(h.length){L.push(" headers: {");L.push(h.join(","+NL));L.push(" },");}
if(mp)L.push(" body: form");
else if(b)L.push(" body: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+")");
L.push("});");
L.push("const data = await res.json();");
L.push("console.log(data);");
return L.join(NL);
}
function axiosSample(e){
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
L.push("const res = await axios({");
L.push(" method: '"+e.method.toLowerCase()+"',");
L.push(" url: '"+sampleUrl(e)+"',");
if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
if(mp)L.push(" data: form");
else if(b)L.push(" data: "+indentBody(bodySkeleton(b.schema)," "));
L.push("});");
L.push("console.log(res.data);");
return L.join(NL);
}
function jquerySample(e){
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
L.push("$