4bnode 4.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +25 -206
  2. package/add-crud.js +6 -106
  3. package/add-docs.js +91 -0
  4. package/add-email.js +63 -0
  5. package/add-security.js +143 -0
  6. package/index.js +76 -8
  7. package/lib/codegen.js +1223 -0
  8. package/lib/codegen.test.js +480 -0
  9. package/lib/indexFile.js +10 -12
  10. package/lib/names.js +4 -21
  11. package/lib/routeFile.js +9 -27
  12. package/lib/sync-skeleton.js +47 -0
  13. package/lib/ui.js +15 -2
  14. package/package.json +4 -2
  15. package/scripts/audit-dev-surface.mjs +153 -0
  16. package/scripts/reveal-passkey.mjs +35 -0
  17. package/skeleton/.4bnode/assets/js/App.js +31 -9
  18. package/skeleton/.4bnode/assets/js/Root.js +1 -5
  19. package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
  20. package/skeleton/.4bnode/assets/js/lib.js +18 -5
  21. package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  22. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
  23. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
  24. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
  25. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
  26. package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
  27. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
  28. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
  29. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  30. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
  31. package/skeleton/.4bnode/codegen.js +1223 -0
  32. package/skeleton/.4bnode/dev-api.js +634 -728
  33. package/skeleton/.4bnode/ui.html +62 -40
  34. package/skeleton/_gitignore +7 -0
  35. package/skeleton/index.js +46 -5
  36. package/skeleton/package.json +4 -1
  37. package/skeleton/src/middleware/apiKey.js +36 -0
  38. package/skeleton/src/middleware/errorHandler.js +15 -0
  39. package/skeleton/src/middleware/roles.js +14 -0
  40. package/skeleton/src/middleware/validate.js +20 -0
  41. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  42. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  43. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  44. package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
  45. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
@@ -0,0 +1,1223 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // 4bnode shared code generation
3
+ //
4
+ // SINGLE SOURCE OF TRUTH for code that is emitted into generated projects.
5
+ //
6
+ // This module is PURE: no `fs`, no `path`, no npm dependencies, no I/O. Every
7
+ // export takes plain values and returns strings. That purity is what lets the
8
+ // same file run in two very different places:
9
+ //
10
+ // 1. The 4bnode CLI (add-*.js, index.js) — imports it from `./lib/codegen.js`.
11
+ // 2. The in-project dashboard (.4bnode/dev-api.js) — imports a SYNCED COPY at
12
+ // `./codegen.js`, because it runs inside the user's project where the
13
+ // package's lib/ does not exist.
14
+ //
15
+ // The copy in skeleton/.4bnode/codegen.js is generated by `lib/sync-skeleton.js`
16
+ // (npm run sync). Do NOT edit that copy by hand — edit THIS file and re-sync.
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ // ── Name helpers ────────────────────────────────────────────────────────────
20
+
21
+ export function toCamelCase(str) {
22
+ return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
23
+ }
24
+
25
+ export function toPascalCase(str) {
26
+ const camel = toCamelCase(str);
27
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
28
+ }
29
+
30
+ export function sanitizeName(input) {
31
+ if (!input || typeof input !== "string") {
32
+ throw new Error("Name is required.");
33
+ }
34
+ const trimmed = input.trim();
35
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
36
+ throw new Error(
37
+ `Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
38
+ );
39
+ }
40
+ return trimmed;
41
+ }
42
+
43
+ // ── Pure string helpers for route files ─────────────────────────────────────
44
+ // These operate on file CONTENT (a string) and return new content. The
45
+ // file-reading/writing wrappers live in lib/routeFile.js (CLI) and inline in
46
+ // dev-api.js (dashboard); both delegate here so the insertion logic is shared.
47
+
48
+ export function addImportToContent(content, importStatement) {
49
+ const stmt = importStatement.trim();
50
+ if (content.includes(stmt)) return content;
51
+ return stmt + "\n" + content;
52
+ }
53
+
54
+ export function insertCodeIntoContent(content, codeBlock) {
55
+ const block = codeBlock.trim();
56
+
57
+ // Prefer inserting before "export default router;" — most reliable anchor.
58
+ const exportIndex = content.lastIndexOf("export default router;");
59
+ if (exportIndex !== -1) {
60
+ return (
61
+ content.slice(0, exportIndex) +
62
+ block +
63
+ "\n\n" +
64
+ content.slice(exportIndex)
65
+ );
66
+ }
67
+
68
+ // Fallback: before the last "});".
69
+ const lastClose = content.lastIndexOf("});");
70
+ if (lastClose !== -1) {
71
+ return content.slice(0, lastClose) + block + "\n" + content.slice(lastClose);
72
+ }
73
+
74
+ return content + "\n" + block + "\n";
75
+ }
76
+
77
+ // ── CRUD route builders ─────────────────────────────────────────────────────
78
+
79
+ export function buildInsertCode(pascalName, fields, dataSource) {
80
+ const assignments = fields
81
+ .map((f) => ` ${f}: ${dataSource}.${f}`)
82
+ .join(",\n");
83
+
84
+ let passwordLogic = "";
85
+ if (fields.includes("password")) {
86
+ passwordLogic = `
87
+ if (newData.password) {
88
+ const salt = await bcrypt.genSalt(10);
89
+ newData.password = await bcrypt.hash(newData.password, salt);
90
+ }`;
91
+ }
92
+
93
+ return `
94
+ router.post('/create', async (req, res) => {
95
+ const newData = {
96
+ ${assignments}
97
+ };
98
+ ${passwordLogic}
99
+ try {
100
+ const newDocument = new ${pascalName}(newData);
101
+ await newDocument.save();
102
+ res.json({ message: 'Data inserted successfully', data: newDocument });
103
+ } catch (err) {
104
+ console.error(err);
105
+ res.status(500).json({ message: 'Server error' });
106
+ }
107
+ });`;
108
+ }
109
+
110
+ export function buildReadCode(pascalName) {
111
+ return `
112
+ router.get('/', async (req, res) => {
113
+ try {
114
+ const documents = await ${pascalName}.find();
115
+ res.json({ data: documents });
116
+ } catch (err) {
117
+ console.error(err);
118
+ res.status(500).json({ message: 'Server error' });
119
+ }
120
+ });
121
+
122
+ router.get('/:id', async (req, res) => {
123
+ try {
124
+ const document = await ${pascalName}.findById(req.params.id);
125
+ if (!document) {
126
+ return res.status(404).json({ message: 'Document not found' });
127
+ }
128
+ res.json({ data: document });
129
+ } catch (err) {
130
+ console.error(err);
131
+ res.status(500).json({ message: 'Server error' });
132
+ }
133
+ });`;
134
+ }
135
+
136
+ export function buildUpdateCode(pascalName, fields, dataSource) {
137
+ const assignments = fields
138
+ .map((f) => ` ${f}: ${dataSource}.${f}`)
139
+ .join(",\n");
140
+
141
+ let passwordLogic = "";
142
+ if (fields.includes("password")) {
143
+ passwordLogic = `
144
+ if (updatedData.password) {
145
+ const salt = await bcrypt.genSalt(10);
146
+ updatedData.password = await bcrypt.hash(updatedData.password, salt);
147
+ }`;
148
+ }
149
+
150
+ return `
151
+ router.put('/:id', async (req, res) => {
152
+ const updatedData = {
153
+ ${assignments}
154
+ };
155
+ ${passwordLogic}
156
+ try {
157
+ const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
158
+ if (!updatedDocument) {
159
+ return res.status(404).json({ message: 'Document not found' });
160
+ }
161
+ res.json({ message: 'Data updated successfully', data: updatedDocument });
162
+ } catch (err) {
163
+ console.error(err);
164
+ res.status(500).json({ message: 'Server error' });
165
+ }
166
+ });`;
167
+ }
168
+
169
+ export function buildDeleteCode(pascalName) {
170
+ return `
171
+ router.delete('/:id', async (req, res) => {
172
+ try {
173
+ const deletedDocument = await ${pascalName}.findByIdAndDelete(req.params.id);
174
+ if (!deletedDocument) {
175
+ return res.status(404).json({ message: 'Document not found' });
176
+ }
177
+ res.json({ message: 'Data deleted successfully', data: deletedDocument });
178
+ } catch (err) {
179
+ console.error(err);
180
+ res.status(500).json({ message: 'Server error' });
181
+ }
182
+ });`;
183
+ }
184
+
185
+ // ── Mongoose model generation ───────────────────────────────────────────────
186
+ // Detailed generator supporting type, ref, required, unique, index, default,
187
+ // and compound/text indexes. `pascalName` is used for both the schema variable
188
+ // and the model name.
189
+
190
+ export function generateMongooseModel(pascalName, fields, indexes = []) {
191
+ const fieldLines = fields.map((f) => {
192
+ const type = f.type || "String";
193
+ const parts = [
194
+ type === "ObjectId"
195
+ ? "type: mongoose.Schema.Types.ObjectId"
196
+ : `type: ${type}`,
197
+ ];
198
+ if (f.ref) parts.push(`ref: '${f.ref}'`);
199
+ if (f.required) parts.push("required: true");
200
+ if (f.unique) parts.push("unique: true");
201
+ if (f.index) parts.push("index: true");
202
+ if (f.default !== undefined && f.default !== "")
203
+ parts.push(`default: ${f.default}`);
204
+ return ` ${f.name}: { ${parts.join(", ")} }`;
205
+ });
206
+
207
+ let indexLines = "";
208
+ for (const idx of indexes) {
209
+ const fieldObj = idx.fields
210
+ .map((f) => {
211
+ if (f.direction === "text") return `${f.field}: 'text'`;
212
+ return `${f.field}: ${f.direction || 1}`;
213
+ })
214
+ .join(", ");
215
+ const options = idx.unique ? ", { unique: true }" : "";
216
+ indexLines += `\n${pascalName}Schema.index({ ${fieldObj} }${options});`;
217
+ }
218
+
219
+ return `import mongoose from 'mongoose';
220
+
221
+ const ${pascalName}Schema = new mongoose.Schema({
222
+ ${fieldLines.join(",\n")}
223
+ }, { timestamps: true });
224
+ ${indexLines}
225
+ const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
226
+
227
+ export default ${pascalName};
228
+ `;
229
+ }
230
+
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+ // Phase 1 — Security & Middleware
233
+ //
234
+ // File-content builders for generated middleware. Index.js wiring (imports +
235
+ // app.use placement) is handled by the caller via `securityWiring()`.
236
+ // ─────────────────────────────────────────────────────────────────────────────
237
+
238
+ // src/middleware/errorHandler.js — central 404 + error handler.
239
+ export function buildErrorHandlerFile() {
240
+ return `// Central error handling. Register AFTER all routes:
241
+ // app.use(notFound);
242
+ // app.use(errorHandler);
243
+ export function notFound(req, res, next) {
244
+ res.status(404).json({ message: \`Not found - \${req.originalUrl}\` });
245
+ }
246
+
247
+ export function errorHandler(err, req, res, next) {
248
+ const status = err.status || err.statusCode || 500;
249
+ console.error(err);
250
+ res.status(status).json({
251
+ message: err.message || 'Server error',
252
+ ...(process.env.NODE_ENV === 'production' ? {} : { stack: err.stack }),
253
+ });
254
+ }
255
+ `;
256
+ }
257
+
258
+ // src/middleware/roles.js — role-based access control (RBAC).
259
+ export function buildRolesGuardFile() {
260
+ return `// Role-based access control. Use AFTER the JWT auth middleware so req.user is set:
261
+ // router.get('/admin', auth, roles('admin'), handler);
262
+ // Requires the login token payload to include a \`role\` (e.g. jwt.sign({ id, role })).
263
+ const roles = (...allowed) => (req, res, next) => {
264
+ if (!req.user) {
265
+ return res.status(401).json({ message: 'Not authenticated' });
266
+ }
267
+ if (allowed.length && !allowed.includes(req.user.role)) {
268
+ return res.status(403).json({ message: 'Forbidden: insufficient role' });
269
+ }
270
+ next();
271
+ };
272
+
273
+ export default roles;
274
+ `;
275
+ }
276
+
277
+ // src/middleware/apiKey.js — static API key auth via x-api-key header.
278
+ export function buildApiKeyFile() {
279
+ return `// API key authentication. Named keys are managed by the 4bnode dashboard and
280
+ // stored in src/api-keys.json. Send a key in the \`x-api-key\` request header:
281
+ // import apiKey from './src/middleware/apiKey.js';
282
+ // app.use('/api/private', apiKey);
283
+ import fs from 'fs';
284
+ import path from 'path';
285
+ import { fileURLToPath } from 'url';
286
+
287
+ const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'api-keys.json');
288
+ let _cache = { mtimeMs: -1, keys: [] };
289
+
290
+ // Read the keys file fresh when it changes (mtime-cached), so keys generated or
291
+ // revoked in the dashboard take effect immediately — no restart needed.
292
+ function loadKeys() {
293
+ try {
294
+ const { mtimeMs } = fs.statSync(KEYS_FILE);
295
+ if (mtimeMs !== _cache.mtimeMs) {
296
+ const list = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8'));
297
+ _cache = { mtimeMs, keys: (Array.isArray(list) ? list : []).map((k) => k && k.key).filter(Boolean) };
298
+ }
299
+ } catch {
300
+ _cache = { mtimeMs: -1, keys: [] };
301
+ }
302
+ return _cache.keys;
303
+ }
304
+
305
+ const apiKey = (req, res, next) => {
306
+ const provided = req.header('x-api-key');
307
+ const valid = loadKeys();
308
+ if (!provided || !valid.includes(provided)) {
309
+ return res.status(401).json({ message: 'Invalid or missing API key' });
310
+ }
311
+ next();
312
+ };
313
+
314
+ export default apiKey;
315
+ `;
316
+ }
317
+
318
+ // src/middleware/validate.js — Zod request validation middleware.
319
+ export function buildValidateFile() {
320
+ return `import { ZodError } from 'zod';
321
+
322
+ // Validate part of the request against a Zod schema. Replaces req[source] with the
323
+ // parsed/coerced data on success. Use as middleware:
324
+ // router.post('/', validate(userSchema), handler); // validates req.body
325
+ // router.get('/', validate(querySchema, 'query'), handler); // validates req.query
326
+ const validate = (schema, source = 'body') => (req, res, next) => {
327
+ const result = schema.safeParse(req[source]);
328
+ if (!result.success) {
329
+ const errors = (result.error instanceof ZodError ? result.error.issues : []).map((i) => ({
330
+ path: i.path.join('.'),
331
+ message: i.message,
332
+ }));
333
+ return res.status(400).json({ message: 'Validation failed', errors });
334
+ }
335
+ req[source] = result.data;
336
+ next();
337
+ };
338
+
339
+ export default validate;
340
+ `;
341
+ }
342
+
343
+ // Map a Mongoose-style field descriptor to a Zod expression (no trailing optionality).
344
+ function zodForField(field) {
345
+ const t = field.type || "String";
346
+ switch (t) {
347
+ case "Number":
348
+ return "z.coerce.number()";
349
+ case "Boolean":
350
+ return "z.coerce.boolean()";
351
+ case "Date":
352
+ return "z.coerce.date()";
353
+ case "ObjectId":
354
+ return "z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid id')";
355
+ case "Array":
356
+ return "z.array(z.any())";
357
+ case "Buffer":
358
+ case "Mixed":
359
+ return "z.any()";
360
+ case "String":
361
+ default:
362
+ return "z.string()";
363
+ }
364
+ }
365
+
366
+ // src/validators/<name>.js — a Zod schema derived from a model's fields.
367
+ export function buildZodSchemaFile(name, fields) {
368
+ const camel = toCamelCase(name);
369
+ const lines = fields.map((f) => {
370
+ let expr = zodForField(f);
371
+ if (!f.required) expr += ".optional()";
372
+ return ` ${f.name}: ${expr},`;
373
+ });
374
+ return `import { z } from 'zod';
375
+
376
+ export const ${camel}Schema = z.object({
377
+ ${lines.join("\n")}
378
+ });
379
+
380
+ export default ${camel}Schema;
381
+ `;
382
+ }
383
+
384
+ // Wire `validate(<model>Schema)` into the write endpoints of a route file, and add
385
+ // the two needed imports. POST/PUT use the full schema; PATCH uses
386
+ // `<schema>.partial()` so partial updates aren't forced to send every required
387
+ // field. GET/DELETE are left alone (no body). Idempotent: skips endpoints that
388
+ // already have a validate(...) middleware. `modelBase` is the validator file base
389
+ // name (e.g. "user" → ../validators/user.js, userSchema). Returns the content
390
+ // unchanged when there are no write endpoints to wire.
391
+ export function wireValidateIntoRouteContent(content, modelBase) {
392
+ const schemaVar = toCamelCase(modelBase) + "Schema";
393
+ // Capture: (router.<method>('/...', <any existing middleware>, )(method)(handler arrow)
394
+ const handlerRe =
395
+ /(router\.(post|put|patch)\([^\n]*?,\s*)((?:async\s*)?\(\s*req\s*,\s*res\s*(?:,\s*next\s*)?\)\s*=>)/g;
396
+ let changed = false;
397
+ const replaced = content.replace(handlerRe, (full, head, method, handler) => {
398
+ if (head.includes("validate(")) return full; // already guarded
399
+ const expr = method === "patch" ? `${schemaVar}.partial()` : schemaVar;
400
+ changed = true;
401
+ return `${head}validate(${expr}), ${handler}`;
402
+ });
403
+ if (!changed) return content;
404
+ let out = addImportToContent(replaced, `import validate from '../middleware/validate.js';`);
405
+ out = addImportToContent(out, `import { ${schemaVar} } from '../validators/${modelBase}.js';`);
406
+ return out;
407
+ }
408
+
409
+ // Anchor comment that marks the central error handler in index.js. Route
410
+ // registration looks for this so new routes are always inserted ABOVE it.
411
+ export const ERROR_HANDLER_MARKER = "// ── Error handling (must be last) ──";
412
+
413
+ // Index.js wiring plan for the selected app-level protections. Returns the imports
414
+ // to add and the code blocks to insert before routes / before the listen call.
415
+ // `features` is a set of: 'helmet', 'rateLimit', 'errorHandler'.
416
+ export function securityWiring(features) {
417
+ const has = (f) => features.includes(f);
418
+ const imports = [];
419
+ const beforeRoutesParts = [];
420
+ let beforeListen = "";
421
+
422
+ if (has("helmet")) {
423
+ imports.push("import helmet from 'helmet';");
424
+ beforeRoutesParts.push("// ── Security headers ──\napp.use(helmet());");
425
+ }
426
+ if (has("rateLimit")) {
427
+ imports.push("import rateLimit from 'express-rate-limit';");
428
+ beforeRoutesParts.push(
429
+ `// ── Rate limiting ──
430
+ const apiLimiter = rateLimit({
431
+ windowMs: 15 * 60 * 1000, // 15 minutes
432
+ max: 100, // limit each IP to 100 requests per window
433
+ standardHeaders: true,
434
+ legacyHeaders: false,
435
+ });
436
+ app.use(apiLimiter);`
437
+ );
438
+ }
439
+ if (has("errorHandler")) {
440
+ imports.push(
441
+ "import { notFound, errorHandler } from './src/middleware/errorHandler.js';"
442
+ );
443
+ beforeListen = `${ERROR_HANDLER_MARKER}\napp.use(notFound);\napp.use(errorHandler);`;
444
+ }
445
+
446
+ return { imports, beforeRoutes: beforeRoutesParts.join("\n\n"), beforeListen };
447
+ }
448
+
449
+ // ── Pure index.js string transforms ─────────────────────────────────────────
450
+ // Used by both the CLI (lib/indexFile.js) and the in-project dashboard
451
+ // (dev-api.js) so the insertion logic lives in exactly one place.
452
+
453
+ // Add an import line after the last existing import. Idempotent.
454
+ export function addImportAfterLastImport(content, importLine) {
455
+ const line = importLine.trim();
456
+ if (content.includes(line)) return content;
457
+ const lines = content.split("\n");
458
+ let lastImportIdx = -1;
459
+ lines.forEach((l, i) => {
460
+ if (l.startsWith("import ")) lastImportIdx = i;
461
+ });
462
+ lines.splice(lastImportIdx + 1, 0, line);
463
+ return lines.join("\n");
464
+ }
465
+
466
+ // Insert a code block just before the server starts listening. If the central
467
+ // error handler is present its marker takes precedence (new code stays above it,
468
+ // the handler stays last). Otherwise anchor on the listen/start CALL — matched by
469
+ // .index so the bare token inside `async function startServer(...)` is never hit.
470
+ export function insertBeforeListenContent(content, codeBlock) {
471
+ const block = codeBlock.trim();
472
+ const markerIdx = content.indexOf(ERROR_HANDLER_MARKER);
473
+ let at;
474
+ if (markerIdx !== -1) {
475
+ at = markerIdx;
476
+ } else {
477
+ const m =
478
+ content.match(/(app|server)\.listen\(port/) || content.match(/^startServer\(/m);
479
+ if (!m) return content + "\n" + block + "\n";
480
+ at = m.index;
481
+ }
482
+ return content.slice(0, at) + block + "\n\n" + content.slice(at);
483
+ }
484
+
485
+ // Insert app-level middleware (e.g. helmet) ahead of the route/start section so it
486
+ // wraps all subsequently-registered routes.
487
+ export function insertBeforeRoutesContent(content, codeBlock) {
488
+ const block = codeBlock.trim();
489
+ const markers = ["// ── Start Server", "// ── Helpers", "\nstartServer("];
490
+ let idx = -1;
491
+ for (const marker of markers) {
492
+ idx = content.indexOf(marker);
493
+ if (idx !== -1) break;
494
+ }
495
+ if (idx === -1) {
496
+ const m = content.match(/^startServer\(/m);
497
+ idx = m ? m.index : content.length;
498
+ }
499
+ return content.slice(0, idx) + block + "\n\n" + content.slice(idx);
500
+ }
501
+
502
+ // ─────────────────────────────────────────────────────────────────────────────
503
+ // Phase 2 — AI Builder
504
+ //
505
+ // The AI never emits raw code. It emits a structured PLAN (models + CRUD routes)
506
+ // that these deterministic builders turn into files — so AI output is held to the
507
+ // same shape and safety as hand-driven generation.
508
+ // ─────────────────────────────────────────────────────────────────────────────
509
+
510
+ export const AI_FIELD_TYPES = [
511
+ "String", "Number", "Boolean", "Date", "ObjectId", "Array", "Mixed", "Buffer",
512
+ ];
513
+ export const AI_OPERATIONS = ["create", "read", "update", "delete"];
514
+
515
+ // Supported AI providers. Each plan is requested with a forced tool/function call
516
+ // for structured output, so the same AI_PLAN_SCHEMA works across providers.
517
+ export const AI_PROVIDERS = {
518
+ openai: {
519
+ id: "openai",
520
+ label: "OpenAI",
521
+ keyEnv: "OPENAI_API_KEY",
522
+ defaultModel: "gpt-4o-mini",
523
+ keyPlaceholder: "sk-...",
524
+ keysUrl: "platform.openai.com",
525
+ },
526
+ anthropic: {
527
+ id: "anthropic",
528
+ label: "Anthropic (Claude)",
529
+ keyEnv: "ANTHROPIC_API_KEY",
530
+ defaultModel: "claude-sonnet-4-6",
531
+ keyPlaceholder: "sk-ant-...",
532
+ keysUrl: "console.anthropic.com",
533
+ },
534
+ };
535
+
536
+ export const DEFAULT_AI_PROVIDER = "openai";
537
+
538
+ // JSON Schema handed to Claude as a forced tool input — guarantees structured output.
539
+ export const AI_PLAN_SCHEMA = {
540
+ type: "object",
541
+ properties: {
542
+ summary: { type: "string", description: "One sentence describing what will be built." },
543
+ models: {
544
+ type: "array",
545
+ description: "Mongoose models to create.",
546
+ items: {
547
+ type: "object",
548
+ properties: {
549
+ name: { type: "string", description: "Singular lowercase model name, e.g. 'post'." },
550
+ fields: {
551
+ type: "array",
552
+ items: {
553
+ type: "object",
554
+ properties: {
555
+ name: { type: "string" },
556
+ type: { type: "string", enum: AI_FIELD_TYPES },
557
+ required: { type: "boolean" },
558
+ unique: { type: "boolean" },
559
+ ref: { type: "string", description: "Referenced model name (PascalCase) when type is ObjectId." },
560
+ },
561
+ required: ["name", "type"],
562
+ },
563
+ },
564
+ },
565
+ required: ["name", "fields"],
566
+ },
567
+ },
568
+ routes: {
569
+ type: "array",
570
+ description: "CRUD API routes to create, each bound to a model.",
571
+ items: {
572
+ type: "object",
573
+ properties: {
574
+ name: { type: "string", description: "Route name, usually plural, e.g. 'posts'." },
575
+ model: { type: "string", description: "The model name this route operates on." },
576
+ operations: { type: "array", items: { type: "string", enum: AI_OPERATIONS } },
577
+ },
578
+ required: ["name", "model", "operations"],
579
+ },
580
+ },
581
+ },
582
+ required: ["summary", "models", "routes"],
583
+ };
584
+
585
+ export const AI_SYSTEM_PROMPT = `You are a backend architect for a Node.js + Express + Mongoose application.
586
+ Turn the user's request into a concrete plan of Mongoose models and CRUD API routes.
587
+ Rules:
588
+ - Respond ONLY by calling the emit_plan tool. Do not write prose or code.
589
+ - Model names are singular and lowercase (e.g. "post", "user").
590
+ - Route names are usually the plural of their model (e.g. "posts").
591
+ - Allowed field types: ${AI_FIELD_TYPES.join(", ")}.
592
+ - For relations between models, use type "ObjectId" with "ref" set to the related model's PascalCase name.
593
+ - Include a "password" field (type String) only when authentication is clearly intended; it will be auto-hashed.
594
+ - Every route must reference one of the models in the plan and choose from operations: ${AI_OPERATIONS.join(", ")}.
595
+ - Keep the plan minimal and faithful to the request; do not invent unrelated models.`;
596
+
597
+ // Coerce an arbitrary string into a safe JS identifier name, or null if impossible.
598
+ function safeIdentifier(name) {
599
+ if (typeof name !== "string") return null;
600
+ const cleaned = name.trim().replace(/[^a-zA-Z0-9_-]/g, "");
601
+ if (!cleaned || !/^[a-zA-Z]/.test(cleaned)) return null;
602
+ return cleaned;
603
+ }
604
+
605
+ // Validate and clean a raw plan from the model into a safe, de-duplicated plan.
606
+ // Defensive: bad models/fields/routes are dropped rather than trusted.
607
+ export function normalizeAiPlan(plan) {
608
+ const out = {
609
+ summary: typeof plan?.summary === "string" ? plan.summary : "",
610
+ models: [],
611
+ routes: [],
612
+ };
613
+
614
+ const modelNames = new Set();
615
+ for (const m of Array.isArray(plan?.models) ? plan.models : []) {
616
+ const ident = safeIdentifier(m?.name);
617
+ if (!ident) continue;
618
+ const name = toCamelCase(ident);
619
+ if (modelNames.has(name)) continue;
620
+
621
+ const fields = [];
622
+ const seen = new Set();
623
+ for (const f of Array.isArray(m?.fields) ? m.fields : []) {
624
+ const fIdent = safeIdentifier(f?.name);
625
+ if (!fIdent || seen.has(fIdent)) continue;
626
+ const type = AI_FIELD_TYPES.includes(f?.type) ? f.type : "String";
627
+ const field = { name: fIdent, type };
628
+ if (f?.required) field.required = true;
629
+ if (f?.unique) field.unique = true;
630
+ const refIdent = type === "ObjectId" ? safeIdentifier(f?.ref) : null;
631
+ if (refIdent) field.ref = toPascalCase(refIdent);
632
+ seen.add(fIdent);
633
+ fields.push(field);
634
+ }
635
+ if (fields.length === 0) continue;
636
+ modelNames.add(name);
637
+ out.models.push({ name, fields });
638
+ }
639
+
640
+ // Map a model's lowercased name to its canonical name so a route that
641
+ // references the model with different capitalization still resolves to the
642
+ // exact file that was written. Without this, the model file (model.name) and
643
+ // the route import path (route.model) can diverge only in first-letter case —
644
+ // harmless on case-insensitive macOS, but a "Cannot find module" crash on a
645
+ // case-sensitive Linux production server.
646
+ const modelByKey = new Map(out.models.map((m) => [m.name.toLowerCase(), m.name]));
647
+
648
+ const routeNames = new Set();
649
+ for (const r of Array.isArray(plan?.routes) ? plan.routes : []) {
650
+ const ident = safeIdentifier(r?.name);
651
+ const model = safeIdentifier(r?.model);
652
+ if (!ident || !model) continue;
653
+ const name = toCamelCase(ident);
654
+ if (routeNames.has(name)) continue;
655
+ let operations = (Array.isArray(r?.operations) ? r.operations : []).filter((o) =>
656
+ AI_OPERATIONS.includes(o)
657
+ );
658
+ if (operations.length === 0) operations = [...AI_OPERATIONS];
659
+ routeNames.add(name);
660
+ const modelCamel = toCamelCase(model);
661
+ const resolvedModel = modelByKey.get(modelCamel.toLowerCase()) || modelCamel;
662
+ out.routes.push({ name, model: resolvedModel, operations });
663
+ }
664
+
665
+ return out;
666
+ }
667
+
668
+ // Build a complete CRUD route file (imports + router + selected operations + export)
669
+ // for a model, reusing the same builders as `add crud`.
670
+ export function buildCrudRouteFile(routeName, modelName, fieldNames = [], operations = AI_OPERATIONS) {
671
+ const pascal = toPascalCase(modelName);
672
+ const needsBcrypt =
673
+ fieldNames.includes("password") &&
674
+ (operations.includes("create") || operations.includes("update"));
675
+
676
+ const importLines = ["import express from 'express';"];
677
+ if (needsBcrypt) importLines.push("import bcrypt from 'bcrypt';");
678
+ importLines.push(`import ${pascal} from '../models/${modelName}.js';`);
679
+
680
+ const blocks = [];
681
+ if (operations.includes("create")) blocks.push(buildInsertCode(pascal, fieldNames, "req.body"));
682
+ if (operations.includes("read")) blocks.push(buildReadCode(pascal));
683
+ if (operations.includes("update")) blocks.push(buildUpdateCode(pascal, fieldNames, "req.body"));
684
+ if (operations.includes("delete")) blocks.push(buildDeleteCode(pascal));
685
+
686
+ return `${importLines.join("\n")}
687
+
688
+ const router = express.Router();
689
+ ${blocks.join("\n")}
690
+
691
+ export default router;
692
+ `;
693
+ }
694
+
695
+ // ─────────────────────────────────────────────────────────────────────────────
696
+ // Dependency bookkeeping — record packages in package.json so the generated app
697
+ // is always installable, even when a live `npm install` is interrupted/offline.
698
+ // ─────────────────────────────────────────────────────────────────────────────
699
+
700
+ // Known versions for packages 4bnode wires into generated apps.
701
+ export const PACKAGE_VERSIONS = {
702
+ helmet: "^8.0.0",
703
+ "express-rate-limit": "^7.4.0",
704
+ zod: "^3.23.0",
705
+ bcrypt: "^5.1.1",
706
+ jsonwebtoken: "^9.0.2",
707
+ "socket.io": "^4.8.1",
708
+ ws: "^8.18.0",
709
+ serialport: "^12.0.0",
710
+ "@serialport/parser-readline": "^12.0.0",
711
+ mongoose: "^8.8.0",
712
+ multer: "^1.4.5-lts.1",
713
+ };
714
+
715
+ // Add packages to package.json (dependencies, or devDependencies when dev:true).
716
+ // Pure: takes the file CONTENT, returns { content, changed }. Skips names already
717
+ // present in either deps or devDeps. Unknown packages fall back to "latest".
718
+ export function addDepsToPackageJson(content, names, { dev = false } = {}) {
719
+ const pkg = JSON.parse(content);
720
+ const key = dev ? "devDependencies" : "dependencies";
721
+ pkg[key] = pkg[key] || {};
722
+ let changed = false;
723
+ for (const name of names) {
724
+ const inDeps = pkg.dependencies && pkg.dependencies[name];
725
+ const inDev = pkg.devDependencies && pkg.devDependencies[name];
726
+ if (!inDeps && !inDev) {
727
+ pkg[key][name] = PACKAGE_VERSIONS[name] || "latest";
728
+ changed = true;
729
+ }
730
+ }
731
+ return { content: JSON.stringify(pkg, null, 2) + "\n", changed };
732
+ }
733
+
734
+ // ─────────────────────────────────────────────────────────────────────────────
735
+ // Request-field detection — used by the API Tester to auto-fill keys from a route.
736
+ // Handles BOTH destructuring (`const { a, b } = req.body`) AND direct member access
737
+ // (`req.body.a`, `req.body['a']`), since generated CRUD routes use the latter.
738
+ // ─────────────────────────────────────────────────────────────────────────────
739
+ export function extractRequestFields(handlerBody, source = "body") {
740
+ const src = source === "query" ? "query" : "body";
741
+ const names = [];
742
+ const seen = new Set();
743
+ const add = (raw) => {
744
+ const n = (raw || "").trim();
745
+ if (n && /^[A-Za-z_$][\w$]*$/.test(n) && !seen.has(n)) {
746
+ seen.add(n);
747
+ names.push(n);
748
+ }
749
+ };
750
+ const body = handlerBody || "";
751
+ // const { a, b: x } = req.<src> → take the key before any ':'
752
+ const destructure = body.match(new RegExp(`const\\s*\\{\\s*([^}]+)\\}\\s*=\\s*req\\.${src}`));
753
+ if (destructure) destructure[1].split(",").forEach((f) => add(f.split(":")[0]));
754
+ // req.<src>.field
755
+ const dot = new RegExp(`req\\.${src}\\.(\\w+)`, "g");
756
+ let m;
757
+ while ((m = dot.exec(body)) !== null) add(m[1]);
758
+ // req.<src>['field'] / req.<src>["field"]
759
+ const bracket = new RegExp(`req\\.${src}\\[\\s*['"](\\w+)['"]\\s*\\]`, "g");
760
+ while ((m = bracket.exec(body)) !== null) add(m[1]);
761
+ return names;
762
+ }
763
+
764
+ // ─────────────────────────────────────────────────────────────────────────────
765
+ // API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
766
+ // Reuses extractRequestFields output (endpoints) and model field metadata.
767
+ // ─────────────────────────────────────────────────────────────────────────────
768
+ function openApiType(t) {
769
+ switch (t) {
770
+ case "Number":
771
+ case "Int": return { type: "integer" };
772
+ case "Float": return { type: "number" };
773
+ case "Boolean": return { type: "boolean" };
774
+ case "Date":
775
+ case "DateTime": return { type: "string", format: "date-time" };
776
+ case "ObjectId": return { type: "string" };
777
+ case "Array": return { type: "array", items: {} };
778
+ case "Buffer": return { type: "string", format: "binary" };
779
+ case "Json":
780
+ case "Mixed": return { type: "object" };
781
+ case "String":
782
+ default: return { type: "string" };
783
+ }
784
+ }
785
+
786
+ // Build an OpenAPI 3.0 document from parsed endpoints + models.
787
+ // endpoints: [{ method, path, fullPath, bodyFields[], queryFields[], params[], fileFields[], hasAuth }]
788
+ // models: [{ name, fields:[{name,type,required}] }]
789
+ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints = [], models = [] } = {}) {
790
+ const paths = {};
791
+ for (const ep of endpoints) {
792
+ const raw = ep.fullPath || ep.path || "/";
793
+ const oaPath = raw.replace(/:(\w+)/g, "{$1}");
794
+ const method = (ep.method || "GET").toLowerCase();
795
+ paths[oaPath] = paths[oaPath] || {};
796
+ const op = { summary: `${ep.method} ${raw}`, responses: { "200": { description: "Success" } } };
797
+
798
+ const parameters = [];
799
+ (ep.params || []).forEach((p) => parameters.push({ name: p, in: "path", required: true, schema: { type: "string" } }));
800
+ (ep.queryFields || []).forEach((f) => parameters.push({ name: f.name, in: "query", required: false, schema: openApiType(f.type) }));
801
+ if (parameters.length) op.parameters = parameters;
802
+
803
+ const bodyFields = ep.bodyFields || [];
804
+ const fileFields = ep.fileFields || [];
805
+ if (["post", "put", "patch"].includes(method) && (bodyFields.length || fileFields.length)) {
806
+ const properties = {};
807
+ const required = [];
808
+ bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
809
+ fileFields.forEach((f) => { properties[f.name] = { type: "string", format: "binary" }; });
810
+ const schema = { type: "object", properties };
811
+ if (required.length) schema.required = required;
812
+ const mime = fileFields.length ? "multipart/form-data" : "application/json";
813
+ op.requestBody = { content: { [mime]: { schema } } };
814
+ }
815
+ if (ep.hasAuth) op.security = [{ bearerAuth: [] }];
816
+ paths[oaPath][method] = op;
817
+ }
818
+
819
+ const schemas = {};
820
+ for (const m of models) {
821
+ const properties = {};
822
+ (m.fields || []).forEach((f) => { properties[f.name] = openApiType(f.type); });
823
+ schemas[toPascalCase(m.name)] = { type: "object", properties };
824
+ }
825
+
826
+ const components = { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } };
827
+ if (Object.keys(schemas).length) components.schemas = schemas;
828
+
829
+ return { openapi: "3.0.0", info: { title, version }, servers: [{ url: "/" }], paths, components };
830
+ }
831
+
832
+ // A self-contained, modern API documentation page rendered from the spec.
833
+ // No Swagger, no external CDN, no third-party branding — pure 4bnode dark style.
834
+ // Two-pane layout: endpoint list on the left, selected endpoint details + an
835
+ // inline "Try it" client on the right. Fetches the spec at runtime.
836
+ export function buildDocsHtml(specUrl = "openapi.json") {
837
+ return `<!doctype html>
838
+ <html lang="en">
839
+ <head>
840
+ <meta charset="utf-8" />
841
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
842
+ <title>API Documentation</title>
843
+ <style>
844
+ * { box-sizing: border-box; }
845
+ :root {
846
+ --bg:#0f1117; --surface:#11141e; --card:#1a1d2b; --card-hover:#21253a; --input:#13161f;
847
+ --border:rgba(255,255,255,0.08); --text:#eceef4; --muted:#8b8fa3; --faint:#626880;
848
+ --accent:#0079ff; --accent-light:#3daefd; --accent-glow:rgba(0,121,255,0.14);
849
+ }
850
+ * { scrollbar-width: thin; }
851
+ html, body { margin:0; padding:0; height:100%; }
852
+ body { background:var(--bg); color:var(--text); line-height:1.5; height:100vh;
853
+ display:flex; flex-direction:column; overflow:hidden;
854
+ font-family:"Noto Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
855
+ .topbar { flex:none; display:flex; align-items:center; gap:14px;
856
+ background:var(--surface); border-bottom:1px solid var(--border); padding:14px 24px; }
857
+ .topbar h1 { font-size:17px; font-weight:700; margin:0;
858
+ background:linear-gradient(135deg,#3daefd,#0079ff); -webkit-background-clip:text;
859
+ background-clip:text; -webkit-text-fill-color:transparent; }
860
+ .pill-ver { font-size:12px; color:var(--accent-light); background:rgba(0,121,255,0.12);
861
+ border:1px solid rgba(0,121,255,0.25); padding:3px 9px; border-radius:999px; }
862
+ .layout { flex:1; display:flex; min-height:0; }
863
+ .sidebar { width:330px; flex:none; border-right:1px solid var(--border);
864
+ overflow-y:auto; padding:14px 12px; }
865
+ .search { width:100%; background:var(--input); border:1px solid var(--border);
866
+ color:var(--text); border-radius:9px; padding:8px 12px; font-size:13px; outline:none; margin-bottom:12px; }
867
+ .search:focus { border-color:var(--accent); }
868
+ .group-title { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px;
869
+ color:var(--faint); margin:14px 6px 6px; }
870
+ .nav-item { display:flex; align-items:center; gap:9px; padding:8px 10px; border-radius:8px;
871
+ cursor:pointer; border:1px solid transparent; }
872
+ .nav-item:hover { background:var(--card-hover); }
873
+ .nav-item.active { background:var(--accent-glow); border-color:rgba(0,121,255,0.35); }
874
+ .nav-method { font-size:9.5px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase;
875
+ color:#fff; padding:3px 0; border-radius:5px; width:46px; flex:none; text-align:center; }
876
+ .nav-path { font-family:ui-monospace,Menlo,monospace; font-size:12.5px; color:var(--text);
877
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
878
+ .main { flex:1; overflow-y:auto; padding:30px 40px; }
879
+ .main-empty { color:var(--faint); display:flex; height:100%; align-items:center; justify-content:center; }
880
+ .detail-head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }
881
+ .method-lg { font-size:13px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase;
882
+ color:#fff; padding:6px 12px; border-radius:8px; }
883
+ .path-lg { font-family:ui-monospace,Menlo,monospace; font-size:18px; color:var(--text); word-break:break-all; }
884
+ .detail-sum { color:var(--muted); margin:0 0 8px; }
885
+ .lock { color:var(--faint); font-size:12px; margin-left:8px; white-space:nowrap; }
886
+ .sec { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px;
887
+ color:var(--faint); margin:22px 0 8px; }
888
+ .row { display:flex; gap:10px; align-items:center; padding:8px 0;
889
+ border-bottom:1px solid var(--border); font-size:13px; }
890
+ .row:last-child { border-bottom:none; }
891
+ .fname { font-family:ui-monospace,Menlo,monospace; color:var(--text); min-width:150px; }
892
+ .ftype { color:var(--accent-light); font-size:12px; }
893
+ .req { color:#ef4444; font-size:11px; font-weight:700; }
894
+ .opt { color:var(--faint); font-size:11px; }
895
+ .resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
896
+ input.f, textarea.f { width:100%; max-width:540px; background:var(--input); border:1px solid var(--border);
897
+ color:var(--text); border-radius:8px; padding:8px 10px; font-size:13px; outline:none;
898
+ font-family:ui-monospace,Menlo,monospace; }
899
+ input.f:focus, textarea.f:focus { border-color:var(--accent); }
900
+ textarea.f { min-height:110px; resize:vertical; }
901
+ .try-field { margin-bottom:10px; }
902
+ .try-label { font-size:12px; color:var(--muted); margin-bottom:4px; display:block; }
903
+ .btn { background:var(--accent); color:#fff; border:none; border-radius:8px; padding:9px 18px;
904
+ font-size:13px; font-weight:600; cursor:pointer; }
905
+ .btn:hover { filter:brightness(1.08); }
906
+ .btn:disabled { opacity:.5; cursor:not-allowed; }
907
+ pre.out { background:#0a0c12; border:1px solid var(--border); border-radius:8px; padding:12px;
908
+ font-size:12.5px; color:#d5d8e3; overflow:auto; max-height:360px; max-width:540px; white-space:pre-wrap;
909
+ word-break:break-word; margin-top:10px; }
910
+ .out-status { font-family:ui-monospace,Menlo,monospace; font-size:12px; margin-top:10px; }
911
+ ::-webkit-scrollbar { width:9px; height:9px; }
912
+ ::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
913
+ </style>
914
+ </head>
915
+ <body>
916
+ <div class="topbar">
917
+ <h1 id="apiTitle">API</h1>
918
+ <span class="pill-ver" id="apiVersion">v1.0.0</span>
919
+ </div>
920
+ <div class="layout">
921
+ <aside class="sidebar">
922
+ <input class="search" id="search" placeholder="Filter endpoints..." />
923
+ <div id="nav"></div>
924
+ </aside>
925
+ <main class="main" id="main"><div class="main-empty">Select an endpoint to view its details.</div></main>
926
+ </div>
927
+ <script>
928
+ (function(){
929
+ var SPEC_URL='${specUrl}';
930
+ var COLORS={get:'#0079ff',post:'#22c55e',put:'#f59e0b',patch:'#a855f7',delete:'#ef4444',head:'#64748b',options:'#64748b'};
931
+ var uid=0; var ITEMS=[];
932
+ function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
933
+ function typeName(sc){if(!sc)return'';if(sc.format==='binary')return'file';if(sc.$ref)return sc.$ref.split('/').pop();return sc.type||'string';}
934
+ function paramsOf(op,where){return (op.parameters||[]).filter(function(p){return p.in===where;});}
935
+ 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||{}};}
936
+ function sampleVal(sc){var t=typeName(sc);if(t==='integer'||t==='number')return 0;if(t==='boolean')return false;return'';}
937
+ 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);}
938
+ function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
939
+ 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;}
940
+
941
+ function detailsHtml(e,id){
942
+ var html='';
943
+ var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
944
+ if(pp.length||qp.length){
945
+ html+='<div class="sec">Parameters</div>';
946
+ pp.concat(qp).forEach(function(p){
947
+ html+='<div class="row"><span class="fname">'+esc(p.name)+'</span><span class="ftype">'+esc(typeName(p.schema)||'string')+' &middot; '+esc(p.in)+'</span>'+(p.required?'<span class="req">required</span>':'<span class="opt">optional</span>')+'</div>';
948
+ });
949
+ }
950
+ if(body){
951
+ html+='<div class="sec">Request body <span class="opt">('+esc(body.mime)+')</span></div>';
952
+ var props=(body.schema&&body.schema.properties)||{};var reqd=(body.schema&&body.schema.required)||[];
953
+ Object.keys(props).forEach(function(k){
954
+ html+='<div class="row"><span class="fname">'+esc(k)+'</span><span class="ftype">'+esc(typeName(props[k]))+'</span>'+(reqd.indexOf(k)>=0?'<span class="req">required</span>':'<span class="opt">optional</span>')+'</div>';
955
+ });
956
+ }
957
+ html+='<div class="sec">Responses</div>';
958
+ var resp=e.op.responses||{};
959
+ Object.keys(resp).forEach(function(code){
960
+ html+='<div class="row"><span class="resp-code">'+esc(code)+'</span><span class="detail-sum" style="margin:0">'+esc((resp[code]||{}).description||'')+'</span></div>';
961
+ });
962
+ html+='<div class="sec">Try it</div>';
963
+ html+='<div class="try-field"><label class="try-label">x-api-key</label><input class="f" id="'+id+'_apikey" placeholder="API key (saved for this browser)" /></div>';
964
+ pp.forEach(function(p){html+='<div class="try-field"><label class="try-label">'+esc(p.name)+' (path)</label><input class="f" data-kind="path" data-name="'+esc(p.name)+'" /></div>';});
965
+ qp.forEach(function(p){html+='<div class="try-field"><label class="try-label">'+esc(p.name)+' (query)</label><input class="f" data-kind="query" data-name="'+esc(p.name)+'" /></div>';});
966
+ if(e.op.security){html+='<div class="try-field"><label class="try-label">Authorization (Bearer token)</label><input class="f" id="'+id+'_auth" placeholder="token" /></div>';}
967
+ if(body){html+='<div class="try-field"><label class="try-label">Body (JSON)</label><textarea class="f" id="'+id+'_body">'+esc(bodySkeleton(body.schema))+'</textarea></div>';}
968
+ html+='<button class="btn" id="'+id+'_send">Send request</button>';
969
+ html+='<div class="out-status" id="'+id+'_status"></div>';
970
+ html+='<pre class="out" id="'+id+'_out" style="display:none"></pre>';
971
+ return html;
972
+ }
973
+
974
+ function wireTry(e,id,root){
975
+ var btn=root.querySelector('#'+id+'_send');if(!btn)return;
976
+ var keyEl=root.querySelector('#'+id+'_apikey');
977
+ if(keyEl){ try{ keyEl.value=localStorage.getItem('docs_api_key')||''; }catch(err){} }
978
+ // Auto-grow the body textarea to fit its content (capped, then scrolls).
979
+ var bodyTa=root.querySelector('#'+id+'_body');
980
+ if(bodyTa){
981
+ var autosize=function(){ bodyTa.style.height='auto'; bodyTa.style.height=Math.min(bodyTa.scrollHeight+2,600)+'px'; };
982
+ bodyTa.addEventListener('input',autosize);
983
+ autosize();
984
+ }
985
+ btn.addEventListener('click',function(){
986
+ var url=e.path;
987
+ root.querySelectorAll('[data-kind=path]').forEach(function(inp){url=url.replace('{'+inp.getAttribute('data-name')+'}',encodeURIComponent(inp.value||''));});
988
+ var qs=[];
989
+ root.querySelectorAll('[data-kind=query]').forEach(function(inp){if(inp.value)qs.push(encodeURIComponent(inp.getAttribute('data-name'))+'='+encodeURIComponent(inp.value));});
990
+ if(qs.length)url+=(url.indexOf('?')>=0?'&':'?')+qs.join('&');
991
+ var opts={method:e.method.toUpperCase(),headers:{}};
992
+ if(keyEl&&keyEl.value){ opts.headers['x-api-key']=keyEl.value; try{ localStorage.setItem('docs_api_key',keyEl.value); }catch(err){} }
993
+ var auth=root.querySelector('#'+id+'_auth');if(auth&&auth.value)opts.headers['Authorization']='Bearer '+auth.value;
994
+ var bodyEl=root.querySelector('#'+id+'_body');
995
+ if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}
996
+ var statusEl=root.querySelector('#'+id+'_status');var outEl=root.querySelector('#'+id+'_out');
997
+ btn.disabled=true;statusEl.textContent='Sending...';var t0=Date.now();
998
+ fetch(url,opts).then(function(r){var ct=r.headers.get('content-type')||'';return r.text().then(function(txt){return {r:r,txt:txt,ct:ct};});}).then(function(d){
999
+ var color=d.r.ok?'#22c55e':'#ef4444';
1000
+ statusEl.innerHTML='<span style="color:'+color+'">'+d.r.status+' '+esc(d.r.statusText)+'</span> &middot; '+(Date.now()-t0)+'ms';
1001
+ var out=d.txt;if(d.ct.indexOf('application/json')>=0){try{out=JSON.stringify(JSON.parse(d.txt),null,2);}catch(err){}}
1002
+ outEl.style.display='block';outEl.textContent=out;
1003
+ }).catch(function(err){statusEl.innerHTML='<span style="color:#ef4444">Request failed</span>';outEl.style.display='block';outEl.textContent=String(err);}).then(function(){btn.disabled=false;});
1004
+ });
1005
+ }
1006
+
1007
+ function showDetail(e,navEl){
1008
+ document.querySelectorAll('.nav-item').forEach(function(n){n.classList.remove('active');});
1009
+ if(navEl)navEl.classList.add('active');
1010
+ var id='ep'+(uid++);
1011
+ var main=document.getElementById('main');
1012
+ var color=COLORS[e.method]||'#64748b';
1013
+ var lock=e.op.security?'<span class="lock">&#128274; requires auth</span>':'';
1014
+ var html='<div class="detail-head"><span class="method-lg" style="background:'+color+'">'+esc(e.method)+'</span><span class="path-lg">'+esc(e.path)+'</span></div>';
1015
+ html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
1016
+ html+=detailsHtml(e,id);
1017
+ main.innerHTML=html;main.scrollTop=0;
1018
+ wireTry(e,id,main);
1019
+ }
1020
+
1021
+ function render(spec){
1022
+ document.getElementById('apiTitle').textContent=(spec.info&&spec.info.title)||'API';
1023
+ document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');
1024
+ ITEMS=collect(spec);
1025
+ var nav=document.getElementById('nav');nav.innerHTML='';
1026
+ if(!ITEMS.length){nav.innerHTML='<div class="group-title">No endpoints</div>';return;}
1027
+ var groups={};ITEMS.forEach(function(e){var k=groupKey(e.path);(groups[k]=groups[k]||[]).push(e);});
1028
+ var first=null;
1029
+ Object.keys(groups).sort().forEach(function(k){
1030
+ var gt=document.createElement('div');gt.className='group-title';gt.textContent=k;nav.appendChild(gt);
1031
+ groups[k].forEach(function(e){
1032
+ var color=COLORS[e.method]||'#64748b';
1033
+ var item=document.createElement('div');item.className='nav-item';
1034
+ item.setAttribute('data-search',(e.method+' '+e.path+' '+(e.op.summary||'')).toLowerCase());
1035
+ item.innerHTML='<span class="nav-method" style="background:'+color+'">'+esc(e.method)+'</span><span class="nav-path">'+esc(e.path)+'</span>';
1036
+ item.addEventListener('click',function(){showDetail(e,item);});
1037
+ nav.appendChild(item);
1038
+ if(!first)first={e:e,item:item};
1039
+ });
1040
+ });
1041
+ if(first)showDetail(first.e,first.item);
1042
+ }
1043
+
1044
+ document.getElementById('search').addEventListener('input',function(ev){
1045
+ var q=ev.target.value.toLowerCase();
1046
+ document.querySelectorAll('.nav-item').forEach(function(c){c.style.display=c.getAttribute('data-search').indexOf(q)>=0?'':'none';});
1047
+ });
1048
+
1049
+ fetch(SPEC_URL).then(function(r){return r.json();}).then(render).catch(function(){
1050
+ document.getElementById('main').innerHTML='<div class="main-empty">Could not load the API spec.</div>';
1051
+ });
1052
+ })();
1053
+ </script>
1054
+ </body>
1055
+ </html>`;
1056
+ }
1057
+
1058
+ // src/docs.js — a router that serves the spec + the self-contained docs page.
1059
+ // Password-only login page for protected docs (no username field). The
1060
+ // <!--ERR--> placeholder is swapped for an error message on a wrong password.
1061
+ export function buildDocsLoginHtml() {
1062
+ return `<!doctype html>
1063
+ <html lang="en"><head>
1064
+ <meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
1065
+ <title>API Documentation</title>
1066
+ <style>
1067
+ *{box-sizing:border-box}
1068
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f1117;color:#eceef4;font-family:"Noto Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
1069
+ form{background:#1a1d2b;border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:28px;width:340px;max-width:90vw;text-align:center}
1070
+ h1{font-size:18px;margin:0 0 6px;background:linear-gradient(135deg,#3daefd,#0079ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
1071
+ p{color:#8b8fa3;font-size:13px;margin:0 0 18px}
1072
+ input{width:100%;background:#13161f;border:1px solid rgba(255,255,255,0.08);border-radius:9px;color:#eceef4;padding:11px 13px;font-size:14px;outline:none}
1073
+ input:focus{border-color:#0079ff}
1074
+ .err{color:#ef4444;font-size:12px;min-height:16px;margin:8px 0 0}
1075
+ button{width:100%;margin-top:14px;background:#0079ff;color:#fff;border:none;border-radius:9px;padding:11px;font-size:14px;font-weight:600;cursor:pointer}
1076
+ button:hover{filter:brightness(1.08)}
1077
+ </style></head>
1078
+ <body>
1079
+ <form method="post" action="/docs/login">
1080
+ <h1>API Documentation</h1>
1081
+ <p>Enter the password to view the docs.</p>
1082
+ <input type="password" name="password" placeholder="Password" autofocus autocomplete="current-password" />
1083
+ <div class="err"><!--ERR--></div>
1084
+ <button type="submit">Unlock</button>
1085
+ </form>
1086
+ </body></html>`;
1087
+ }
1088
+
1089
+ export function buildDocsRouter() {
1090
+ return `import express from 'express';
1091
+ import fs from 'fs';
1092
+ import path from 'path';
1093
+ import crypto from 'crypto';
1094
+ import { fileURLToPath } from 'url';
1095
+
1096
+ const router = express.Router();
1097
+ const DIR = path.dirname(fileURLToPath(import.meta.url));
1098
+ const SPEC = path.join(DIR, 'openapi.json');
1099
+ const AUTH = path.join(DIR, 'docs-auth.json');
1100
+
1101
+ // Optional password protection. If src/docs-auth.json holds a password hash, the
1102
+ // whole /docs surface requires a single password (NO username) via a small login
1103
+ // page + cookie. Set/remove the password from the dashboard (API Docs). Read live
1104
+ // (mtime-cached), so changes apply without a restart.
1105
+ let _authCache = { mtimeMs: -1, hash: null };
1106
+ function docsHash() {
1107
+ try {
1108
+ const { mtimeMs } = fs.statSync(AUTH);
1109
+ if (mtimeMs !== _authCache.mtimeMs) {
1110
+ const data = JSON.parse(fs.readFileSync(AUTH, 'utf8'));
1111
+ _authCache = { mtimeMs, hash: (data && data.hash) || null };
1112
+ }
1113
+ } catch { _authCache = { mtimeMs: -1, hash: null }; }
1114
+ return _authCache.hash;
1115
+ }
1116
+ function safeEq(a, b) {
1117
+ try { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); } catch { return false; }
1118
+ }
1119
+ function cookieToken(req) {
1120
+ const m = (req.headers.cookie || '').match(/(?:^|; )docs_auth=([^;]+)/);
1121
+ return m ? m[1] : null;
1122
+ }
1123
+ const LOGIN_HTML = ${JSON.stringify(buildDocsLoginHtml())};
1124
+
1125
+ // Password submit — reachable without auth.
1126
+ router.post('/login', (req, res) => {
1127
+ const expected = docsHash();
1128
+ if (!expected) return res.redirect('/docs');
1129
+ const got = crypto.createHash('sha256').update(String((req.body && req.body.password) || '')).digest('hex');
1130
+ if (safeEq(got, expected)) {
1131
+ res.setHeader('Set-Cookie', 'docs_auth=' + expected + '; Path=/docs; HttpOnly; SameSite=Lax; Max-Age=86400');
1132
+ return res.redirect('/docs');
1133
+ }
1134
+ return res.status(401).type('html').send(LOGIN_HTML.replace('<!--ERR-->', 'Incorrect password'));
1135
+ });
1136
+
1137
+ // Gate the rest of /docs behind the password (when one is set).
1138
+ router.use((req, res, next) => {
1139
+ const expected = docsHash();
1140
+ if (!expected) return next(); // no password → open
1141
+ if (safeEq(cookieToken(req), expected)) return next();
1142
+ return res.status(401).type('html').send(LOGIN_HTML);
1143
+ });
1144
+
1145
+ // Interactive API documentation. The page is fully self-contained (no external
1146
+ // CDN); the spec is generated by 4bnode and served from this app.
1147
+ router.get('/openapi.json', (req, res) => {
1148
+ if (!fs.existsSync(SPEC)) return res.status(404).json({ error: 'API spec not generated yet.' });
1149
+ res.sendFile(SPEC);
1150
+ });
1151
+
1152
+ router.get('/', (req, res) => {
1153
+ res.type('html').send(${JSON.stringify(buildDocsHtml("/docs/openapi.json"))});
1154
+ });
1155
+
1156
+ export default router;
1157
+ `;
1158
+ }
1159
+
1160
+ // ─────────────────────────────────────────────────────────────────────────────
1161
+ // Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
1162
+ // ─────────────────────────────────────────────────────────────────────────────
1163
+ export const MAIL_PROVIDERS = {
1164
+ brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
1165
+ sendgrid: { label: "SendGrid", host: "smtp.sendgrid.net", port: 587, secure: false, user: "apikey", hint: "User is literally 'apikey'; pass is your API key" },
1166
+ mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
1167
+ gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
1168
+ mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
1169
+ smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
1170
+ };
1171
+
1172
+ // src/services/mailer.js — a configured nodemailer transport + sendMail helper + template.
1173
+ export function buildMailerService() {
1174
+ return `import nodemailer from 'nodemailer';
1175
+
1176
+ // Transport is configured from SMTP_* env vars (see .env). Works with Brevo,
1177
+ // SendGrid, Mailgun, Gmail, Mailtrap, or any SMTP server.
1178
+ const transporter = nodemailer.createTransport({
1179
+ host: process.env.SMTP_HOST,
1180
+ port: Number(process.env.SMTP_PORT) || 587,
1181
+ secure: process.env.SMTP_SECURE === 'true', // true for port 465
1182
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
1183
+ });
1184
+
1185
+ // Wrap content in a simple, responsive HTML email.
1186
+ export function emailTemplate({ title = '', body = '' } = {}) {
1187
+ return \`<!doctype html>
1188
+ <html>
1189
+ <body style="margin:0;background:#f4f5f7;font-family:Arial,Helvetica,sans-serif;">
1190
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:24px 0;">
1191
+ <tr><td align="center">
1192
+ <table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:10px;overflow:hidden;">
1193
+ <tr><td style="background:#0079ff;padding:18px 28px;color:#fff;font-size:18px;font-weight:bold;">\${title}</td></tr>
1194
+ <tr><td style="padding:28px;color:#333;font-size:15px;line-height:1.6;">\${body}</td></tr>
1195
+ <tr><td style="padding:16px 28px;color:#9aa0ab;font-size:12px;border-top:1px solid #eee;">Sent by your app</td></tr>
1196
+ </table>
1197
+ </td></tr>
1198
+ </table>
1199
+ </body>
1200
+ </html>\`;
1201
+ }
1202
+
1203
+ // Send an email. Pass html or text (or use emailTemplate for html).
1204
+ export async function sendMail({ to, subject, html, text, from } = {}) {
1205
+ if (!to || !subject) throw new Error('sendMail requires { to, subject }');
1206
+ return transporter.sendMail({
1207
+ from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
1208
+ to,
1209
+ subject,
1210
+ text,
1211
+ html,
1212
+ });
1213
+ }
1214
+
1215
+ // Verify the SMTP connection/credentials.
1216
+ export function verifyMailer() {
1217
+ return transporter.verify();
1218
+ }
1219
+
1220
+ export default { sendMail, emailTemplate, verifyMailer, transporter };
1221
+ `;
1222
+ }
1223
+