UNPKG

4bnode

Version:

4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.

1,172 lines (1,085 loc) 85.9 kB
// ───────────────────────────────────────────────────────────────────────────── // 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');} 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("$