4bnode 4.0.9 → 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 +41 -3
  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
@@ -6,9 +6,71 @@ import { promisify } from 'util';
6
6
  import { pathToFileURL } from 'url';
7
7
  const execAsync = promisify(exec);
8
8
  import crypto from 'crypto';
9
+ // Shared, dependency-free code generation. This file is a verbatim copy of the
10
+ // 4bnode package's lib/codegen.js, kept in sync by `npm run sync`. Edit codegen
11
+ // in the package, not here — it is regenerated when a project is scaffolded.
12
+ import {
13
+ toCamelCase,
14
+ toPascalCase,
15
+ sanitizeName,
16
+ addImportToContent,
17
+ insertCodeIntoContent,
18
+ buildInsertCode,
19
+ buildReadCode,
20
+ buildUpdateCode,
21
+ buildDeleteCode,
22
+ generateMongooseModel,
23
+ buildZodSchemaFile,
24
+ wireValidateIntoRouteContent,
25
+ addImportAfterLastImport,
26
+ insertBeforeListenContent,
27
+ AI_PLAN_SCHEMA,
28
+ AI_SYSTEM_PROMPT,
29
+ AI_PROVIDERS,
30
+ DEFAULT_AI_PROVIDER,
31
+ normalizeAiPlan,
32
+ buildCrudRouteFile,
33
+ addDepsToPackageJson,
34
+ extractRequestFields,
35
+ buildOpenApiSpec,
36
+ buildDocsRouter,
37
+ MAIL_PROVIDERS,
38
+ buildMailerService,
39
+ } from './codegen.js';
9
40
 
10
41
  const router = express.Router();
11
42
 
43
+ // ── Network restriction ─────────────────────────────────────────────────────
44
+ // This router can write files and run shell commands — it is a remote-code-
45
+ // execution surface. Refuse any request that does not originate from the local
46
+ // machine, unless an operator has *explicitly* opted into remote access (in
47
+ // which case a passkey becomes mandatory, enforced by the auth middleware
48
+ // below). The check uses the raw TCP peer address — never X-Forwarded-* — so it
49
+ // cannot be spoofed by a request header. Behind a reverse proxy the peer is the
50
+ // proxy itself, so external traffic must also be blocked at the edge.
51
+ // Defense-in-depth production kill-switch. The dashboard should never be mounted
52
+ // in production (see index.js), but if it somehow is — a manual mount, a runtime
53
+ // NODE_ENV flip, a misconfigured host — refuse every request and behave as if the
54
+ // route does not exist (404, no information leak). Set _4BNODE_DASHBOARD=on to
55
+ // force-enable (loopback + passkey still apply).
56
+ const DASHBOARD_ENABLED =
57
+ process.env._4BNODE_DASHBOARD === 'on' ||
58
+ (process.env.NODE_ENV !== 'production' && process.env._4BNODE_DASHBOARD !== 'off');
59
+ router.use((req, res, next) => {
60
+ if (!DASHBOARD_ENABLED) return res.status(404).end();
61
+ next();
62
+ });
63
+
64
+ const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
65
+ function isLoopbackRequest(req) {
66
+ const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
67
+ return ip === '127.0.0.1' || ip === '::1';
68
+ }
69
+ router.use((req, res, next) => {
70
+ if (ALLOW_REMOTE || isLoopbackRequest(req)) return next();
71
+ return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' });
72
+ });
73
+
12
74
  // ── Console Log Capture ─────────────────────────────────────────────────────
13
75
  const _logBuffer = [];
14
76
  const _logMaxSize = 500;
@@ -59,21 +121,86 @@ router.use('/assets', express.static(path.join(__4bnodeDir, 'assets')));
59
121
  function getPasskeyHash() {
60
122
  const file = path.join(__4bnodeDir, '.passkey');
61
123
  if (!fs.existsSync(file)) return null;
62
- return fs.readFileSync(file, 'utf8').trim();
124
+ return fs.readFileSync(file, 'utf8').trim() || null;
63
125
  }
64
126
 
65
- function hashPasskey(pin) {
66
- return crypto.createHash('sha256').update(pin).digest('hex');
127
+ // Constant-time verification. Supports the current salted-scrypt format
128
+ // (`scrypt$<saltHex>$<hashHex>`) and the legacy unsalted sha256 hex digest so
129
+ // apps generated before the upgrade keep working.
130
+ function verifyPasskey(candidate, stored) {
131
+ if (!candidate || !stored) return false;
132
+ try {
133
+ if (stored.startsWith('scrypt$')) {
134
+ const [, saltHex, hashHex] = stored.split('$');
135
+ const salt = Buffer.from(saltHex, 'hex');
136
+ const expected = Buffer.from(hashHex, 'hex');
137
+ const actual = crypto.scryptSync(String(candidate), salt, expected.length);
138
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
139
+ }
140
+ const expected = Buffer.from(stored, 'hex');
141
+ const actual = crypto.createHash('sha256').update(String(candidate)).digest();
142
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ // Brute-force throttle. After AUTH_MAX_FAILS wrong guesses from one peer, lock it
149
+ // out for AUTH_LOCK_MS. In-memory (clears on restart) — enough to stop online
150
+ // guessing, which is the realistic attack once the dashboard is reachable.
151
+ const AUTH_MAX_FAILS = 5;
152
+ const AUTH_LOCK_MS = 60_000;
153
+ const _authFails = new Map(); // ip -> { fails, lockedUntil }
154
+ function authClientKey(req) {
155
+ return String((req.socket && req.socket.remoteAddress) || 'unknown').replace(/^::ffff:/, '');
156
+ }
157
+ function authLockState(req) {
158
+ const e = _authFails.get(authClientKey(req));
159
+ if (e && e.lockedUntil > Date.now()) {
160
+ return { locked: true, retryAfter: Math.ceil((e.lockedUntil - Date.now()) / 1000) };
161
+ }
162
+ return { locked: false, retryAfter: 0 };
163
+ }
164
+ function recordAuthFailure(req) {
165
+ const key = authClientKey(req);
166
+ const e = _authFails.get(key) || { fails: 0, lockedUntil: 0 };
167
+ e.fails += 1;
168
+ if (e.fails >= AUTH_MAX_FAILS) { e.lockedUntil = Date.now() + AUTH_LOCK_MS; e.fails = 0; }
169
+ _authFails.set(key, e);
170
+ }
171
+ function recordAuthSuccess(req) {
172
+ _authFails.delete(authClientKey(req));
67
173
  }
68
174
 
69
175
  // Verify passkey endpoint (no auth middleware needed here)
70
176
  router.post('/api/auth', (req, res) => {
71
- const { passkey } = req.body;
177
+ const lock = authLockState(req);
178
+ if (lock.locked) {
179
+ res.set('Retry-After', String(lock.retryAfter));
180
+ return res.status(429).json({ error: `Too many attempts. Try again in ${lock.retryAfter}s.` });
181
+ }
182
+ const { passkey } = req.body || {};
72
183
  const stored = getPasskeyHash();
73
- if (!stored) return res.json({ ok: true });
74
- if (!passkey || hashPasskey(passkey) !== stored) {
184
+ if (!stored) {
185
+ // No passkey configured: permit only loopback dev. A missing passkey must
186
+ // never authenticate a remote caller (deny by default).
187
+ if (!isLoopbackRequest(req)) {
188
+ return res.status(401).json({ error: 'A dashboard passkey must be configured for remote access.' });
189
+ }
190
+ return res.json({ ok: true });
191
+ }
192
+ if (!verifyPasskey(passkey, stored)) {
193
+ // Only count a real guess (non-empty) so an unauthenticated page load that
194
+ // probes with an empty passkey doesn't burn the lockout budget.
195
+ if (passkey) recordAuthFailure(req);
196
+ const after = authLockState(req);
197
+ if (after.locked) {
198
+ res.set('Retry-After', String(after.retryAfter));
199
+ return res.status(429).json({ error: `Too many attempts. Try again in ${after.retryAfter}s.` });
200
+ }
75
201
  return res.status(401).json({ error: 'Invalid passkey' });
76
202
  }
203
+ recordAuthSuccess(req);
77
204
  res.json({ ok: true });
78
205
  });
79
206
 
@@ -83,40 +210,29 @@ router.use('/api', (req, res, next) => {
83
210
  // SSE endpoint handles its own auth via query param (EventSource can't send headers)
84
211
  if (req.path === '/logs/stream') return next();
85
212
  const stored = getPasskeyHash();
86
- if (!stored) return next();
213
+ if (!stored) {
214
+ // Deny by default: a missing passkey may only pass for loopback dev, never
215
+ // for a remote caller (relevant when _4BNODE_ALLOW_REMOTE is enabled).
216
+ if (!isLoopbackRequest(req)) return res.status(401).json({ error: 'Unauthorized' });
217
+ return next();
218
+ }
219
+ const lock = authLockState(req);
220
+ if (lock.locked) {
221
+ res.set('Retry-After', String(lock.retryAfter));
222
+ return res.status(429).json({ error: 'Too many attempts. Try again later.' });
223
+ }
87
224
  const passkey = req.headers['x-passkey'];
88
- if (!passkey || hashPasskey(passkey) !== stored) {
225
+ if (!verifyPasskey(passkey, stored)) {
226
+ if (passkey) recordAuthFailure(req);
89
227
  return res.status(401).json({ error: 'Unauthorized' });
90
228
  }
91
229
  next();
92
230
  });
93
231
 
94
232
  // ---------------------------------------------------------------------------
95
- // Utility functions (self-contained, no external imports)
233
+ // Utility functions (name helpers + code builders are imported from ./codegen.js)
96
234
  // ---------------------------------------------------------------------------
97
235
 
98
- function toCamelCase(str) {
99
- return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
100
- }
101
-
102
- function toPascalCase(str) {
103
- const camel = toCamelCase(str);
104
- return camel.charAt(0).toUpperCase() + camel.slice(1);
105
- }
106
-
107
- function sanitizeName(input) {
108
- if (!input || typeof input !== "string") {
109
- throw new Error("Name is required.");
110
- }
111
- const trimmed = input.trim();
112
- if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
113
- throw new Error(
114
- `Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
115
- );
116
- }
117
- return trimmed;
118
- }
119
-
120
236
  function listModels() {
121
237
  const modelsDir = path.join(process.cwd(), "src", "models");
122
238
  if (!fs.existsSync(modelsDir)) {
@@ -167,6 +283,41 @@ function extractSchemaBlock(content) {
167
283
  return null;
168
284
  }
169
285
 
286
+ // The fields a route actually accepts in its body — used to generate a Zod schema
287
+ // scoped to THAT route (not the whole model, which many routes share with differing
288
+ // field subsets). Pulls types from the model where known; skips File fields (not in
289
+ // req.body). Returns null when the route takes no validatable body.
290
+ function routeValidatorFields(type, opts) {
291
+ const { findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName } = opts;
292
+ const modelFields = {};
293
+ try {
294
+ if (modelName) (getModelSchemaDetailed(sanitizeName(modelName)).fields || []).forEach((f) => { modelFields[f.name] = f; });
295
+ } catch {}
296
+ const make = (name, fallbackType, required) => {
297
+ const mf = modelFields[name];
298
+ return { name, type: (mf && mf.type) || fallbackType || 'String', required };
299
+ };
300
+ if (type === 'login') {
301
+ const fields = [make(findField, 'String', true)];
302
+ if (hasPassword) fields.push(make(passwordField, 'String', true));
303
+ return fields;
304
+ }
305
+ if (type === 'register') {
306
+ const regFields = (registerFields && registerFields.length) ? registerFields : [findField];
307
+ return regFields.map((f) => make(f, 'String', true));
308
+ }
309
+ if (type === 'crud' || type === 'methods') {
310
+ return (allFieldsCombined || [])
311
+ .filter((f) => f.type !== 'File')
312
+ .map((f) => {
313
+ const mf = modelFields[f.name];
314
+ const required = mf ? !!mf.required : f.required !== false;
315
+ return { name: f.name, type: (f.type && f.type !== 'File' && f.type) || (mf && mf.type) || 'String', required };
316
+ });
317
+ }
318
+ return null;
319
+ }
320
+
170
321
  function getModelSchemaDetailed(modelName) {
171
322
  const modelFilePath = path.join(
172
323
  process.cwd(),
@@ -259,40 +410,21 @@ function getModelSchema(modelName) {
259
410
  // Route file manipulation functions
260
411
  // ---------------------------------------------------------------------------
261
412
 
413
+ // File-based wrappers around the shared pure string helpers in codegen.js.
262
414
  function addImportToRoute(filePath, importStatement) {
263
- let content = fs.readFileSync(filePath, "utf8");
264
- if (!content.includes(importStatement.trim())) {
265
- content = importStatement.trim() + "\n" + content;
415
+ const original = fs.readFileSync(filePath, "utf8");
416
+ const content = addImportToContent(original, importStatement);
417
+ if (content !== original) {
266
418
  fs.writeFileSync(filePath, content, "utf8");
267
419
  }
268
420
  return content;
269
421
  }
270
422
 
271
423
  function insertCodeIntoRoute(filePath, codeBlock) {
272
- let content = fs.readFileSync(filePath, "utf8");
273
-
274
- // Prefer inserting before "export default router;" as it's more reliable
275
- const exportIndex = content.lastIndexOf("export default router;");
276
- if (exportIndex !== -1) {
277
- content =
278
- content.slice(0, exportIndex) +
279
- codeBlock.trim() +
280
- "\n\n" +
281
- content.slice(exportIndex);
282
- } else {
283
- // Fallback to inserting before last "});"
284
- const lastClose = content.lastIndexOf("});");
285
- if (lastClose !== -1) {
286
- content =
287
- content.slice(0, lastClose) +
288
- codeBlock.trim() +
289
- "\n" +
290
- content.slice(lastClose);
291
- } else {
292
- content += "\n" + codeBlock.trim() + "\n";
293
- }
294
- }
295
-
424
+ const content = insertCodeIntoContent(
425
+ fs.readFileSync(filePath, "utf8"),
426
+ codeBlock
427
+ );
296
428
  fs.writeFileSync(filePath, content, "utf8");
297
429
  }
298
430
 
@@ -308,154 +440,10 @@ function getRequestDataSource(method) {
308
440
  return method === "get" || method === "delete" ? "req.query" : "req.body";
309
441
  }
310
442
 
311
- // ---------------------------------------------------------------------------
312
- // CRUD code builders
313
- // ---------------------------------------------------------------------------
314
-
315
- function buildInsertCode(pascalName, fields, dataSource) {
316
- const assignments = fields
317
- .map((f) => ` ${f}: ${dataSource}.${f}`)
318
- .join(",\n");
319
-
320
- let passwordLogic = "";
321
- if (fields.includes("password")) {
322
- passwordLogic = `
323
- if (newData.password) {
324
- const salt = await bcrypt.genSalt(10);
325
- newData.password = await bcrypt.hash(newData.password, salt);
326
- }`;
327
- }
328
-
329
- return `
330
- router.post('/create', async (req, res) => {
331
- const newData = {
332
- ${assignments}
333
- };
334
- ${passwordLogic}
335
- try {
336
- const newDocument = new ${pascalName}(newData);
337
- await newDocument.save();
338
- res.json({ message: 'Data inserted successfully', data: newDocument });
339
- } catch (err) {
340
- console.error(err);
341
- res.status(500).json({ message: 'Server error' });
342
- }
343
- });`;
344
- }
345
-
346
- function buildReadCode(pascalName) {
347
- return `
348
- router.get('/', async (req, res) => {
349
- try {
350
- const documents = await ${pascalName}.find();
351
- res.json({ data: documents });
352
- } catch (err) {
353
- console.error(err);
354
- res.status(500).json({ message: 'Server error' });
355
- }
356
- });
357
-
358
- router.get('/:id', async (req, res) => {
359
- try {
360
- const document = await ${pascalName}.findById(req.params.id);
361
- if (!document) {
362
- return res.status(404).json({ message: 'Document not found' });
363
- }
364
- res.json({ data: document });
365
- } catch (err) {
366
- console.error(err);
367
- res.status(500).json({ message: 'Server error' });
368
- }
369
- });`;
370
- }
371
-
372
- function buildUpdateCode(pascalName, fields, dataSource) {
373
- const assignments = fields
374
- .map((f) => ` ${f}: ${dataSource}.${f}`)
375
- .join(",\n");
376
-
377
- let passwordLogic = "";
378
- if (fields.includes("password")) {
379
- passwordLogic = `
380
- if (updatedData.password) {
381
- const salt = await bcrypt.genSalt(10);
382
- updatedData.password = await bcrypt.hash(updatedData.password, salt);
383
- }`;
384
- }
385
-
386
- return `
387
- router.put('/:id', async (req, res) => {
388
- const updatedData = {
389
- ${assignments}
390
- };
391
- ${passwordLogic}
392
- try {
393
- const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
394
- if (!updatedDocument) {
395
- return res.status(404).json({ message: 'Document not found' });
396
- }
397
- res.json({ message: 'Data updated successfully', data: updatedDocument });
398
- } catch (err) {
399
- console.error(err);
400
- res.status(500).json({ message: 'Server error' });
401
- }
402
- });`;
403
- }
404
-
405
- function buildDeleteCode(pascalName) {
406
- return `
407
- router.delete('/:id', async (req, res) => {
408
- try {
409
- const deletedDocument = await ${pascalName}.findByIdAndDelete(req.params.id);
410
- if (!deletedDocument) {
411
- return res.status(404).json({ message: 'Document not found' });
412
- }
413
- res.json({ message: 'Data deleted successfully', data: deletedDocument });
414
- } catch (err) {
415
- console.error(err);
416
- res.status(500).json({ message: 'Server error' });
417
- }
418
- });`;
419
- }
420
-
421
- // ---------------------------------------------------------------------------
422
- // Schema file generation helper
423
- // ---------------------------------------------------------------------------
424
-
425
- function generateSchemaFileContent(pascalName, fields, indexes = []) {
426
- const fieldLines = fields.map((f) => {
427
- const type = f.type || 'String';
428
- const parts = [type === 'ObjectId' ? 'type: mongoose.Schema.Types.ObjectId' : `type: ${type}`];
429
- if (f.ref) parts.push(`ref: '${f.ref}'`);
430
- if (f.required) parts.push('required: true');
431
- if (f.unique) parts.push('unique: true');
432
- if (f.index) parts.push('index: true');
433
- if (f.default !== undefined && f.default !== '') parts.push(`default: ${f.default}`);
434
- return ` ${f.name}: { ${parts.join(', ')} }`;
435
- });
436
-
437
- // Add index lines
438
- let indexLines = '';
439
- for (const idx of indexes) {
440
- const fieldObj = idx.fields.map(f => {
441
- if (f.direction === 'text') return `${f.field}: 'text'`;
442
- return `${f.field}: ${f.direction || 1}`;
443
- }).join(', ');
444
- const options = idx.unique ? ', { unique: true }' : '';
445
- indexLines += `\n${pascalName}Schema.index({ ${fieldObj} }${options});`;
446
- }
447
-
448
- return `import mongoose from 'mongoose';
449
-
450
- const ${pascalName}Schema = new mongoose.Schema({
451
- ${fieldLines.join(',\n')}
452
- }, { timestamps: true });
453
- ${indexLines}
454
- const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
455
-
456
- export default ${pascalName};
457
- `;
458
- }
443
+ // CRUD route builders and the Mongoose model generator are imported from
444
+ // ./codegen.js. `generateSchemaFileContent` is kept as a thin alias so existing
445
+ // call sites below remain unchanged.
446
+ const generateSchemaFileContent = generateMongooseModel;
459
447
 
460
448
  // ---------------------------------------------------------------------------
461
449
  // API Routes
@@ -610,6 +598,7 @@ router.post('/api/models', (req, res) => {
610
598
 
611
599
  const content = generateSchemaFileContent(pascalName, fields, indexes);
612
600
  fs.writeFileSync(filePath, content, 'utf8');
601
+ refreshDocsSpec();
613
602
  res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
614
603
  } catch (err) {
615
604
  res.status(500).json({ error: err.message });
@@ -630,6 +619,7 @@ router.put('/api/models/:name', (req, res) => {
630
619
 
631
620
  const content = generateSchemaFileContent(pascalName, fields, indexes);
632
621
  fs.writeFileSync(filePath, content, 'utf8');
622
+ refreshDocsSpec();
633
623
  res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
634
624
  } catch (err) {
635
625
  res.status(500).json({ error: err.message });
@@ -646,6 +636,7 @@ router.delete('/api/models/:name', (req, res) => {
646
636
  }
647
637
 
648
638
  fs.unlinkSync(filePath);
639
+ refreshDocsSpec();
649
640
  res.json({ message: `Model "${name}" deleted.` });
650
641
  } catch (err) {
651
642
  res.status(500).json({ error: err.message });
@@ -692,6 +683,7 @@ router.delete('/api/routes/:name', (req, res) => {
692
683
 
693
684
  // Delete the route file
694
685
  fs.unlinkSync(filePath);
686
+ refreshDocsSpec();
695
687
 
696
688
  // Send response BEFORE modifying index.js (nodemon restart kills connection)
697
689
  res.json({ message: `Route "${safeName}" deleted successfully.` });
@@ -711,27 +703,6 @@ router.delete('/api/routes/:name', (req, res) => {
711
703
  }
712
704
  });
713
705
 
714
- // PUT /api/routes/:name/content — edit route source code
715
- // ---------------------------------------------------------------------------
716
- router.put('/api/routes/:name/content', (req, res) => {
717
- try {
718
- const name = req.params.name;
719
- const { content } = req.body;
720
- if (!content || typeof content !== 'string') {
721
- return res.status(400).json({ error: 'Content is required.' });
722
- }
723
- const routeFile = name.endsWith('.js') ? name : name + '.js';
724
- const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
725
- if (!fs.existsSync(filePath)) {
726
- return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
727
- }
728
- fs.writeFileSync(filePath, content, 'utf8');
729
- res.json({ message: `Route "${routeFile}" updated successfully.` });
730
- } catch (err) {
731
- if (!res.headersSent) res.status(500).json({ error: err.message });
732
- }
733
- });
734
-
735
706
  // ---------------------------------------------------------------------------
736
707
  // POST /api/routes/:name/add-method — add handlers to existing route
737
708
  // ---------------------------------------------------------------------------
@@ -921,11 +892,12 @@ router.post('/api/routes/:name/add-method', async (req, res) => {
921
892
 
922
893
  // Install packages
923
894
  if (packagesToInstall.length > 0) {
924
- try { await execAsync(`npm install ${[...new Set(packagesToInstall)].join(' ')}`, { cwd: process.cwd() }); } catch {}
895
+ await installDeps([...new Set(packagesToInstall)]);
925
896
  }
926
897
 
927
898
  insertHandler(handlers);
928
899
  fs.writeFileSync(filePath, content, 'utf8');
900
+ refreshDocsSpec();
929
901
 
930
902
  const label = routeType === 'methods' ? newMethods.map(m => m.toUpperCase()).join(', ') : routeType.toUpperCase();
931
903
  res.json({ message: `${label} added to "${name}" route.`, content });
@@ -934,91 +906,6 @@ router.post('/api/routes/:name/add-method', async (req, res) => {
934
906
  }
935
907
  });
936
908
 
937
- // GET /api/routes/:name/share — generate shareable endpoint docs with field metadata
938
- router.get('/api/routes/:name/share', (req, res) => {
939
- try {
940
- const name = req.params.name.replace(/\.js$/, '');
941
- const routeFile = name + '.js';
942
- const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
943
- if (!fs.existsSync(filePath)) {
944
- return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
945
- }
946
- const content = fs.readFileSync(filePath, 'utf8');
947
-
948
- // Determine the route prefix from index.js
949
- const indexPath = path.join(process.cwd(), 'index.js');
950
- let prefix = '/api/' + name;
951
- if (fs.existsSync(indexPath)) {
952
- const indexContent = fs.readFileSync(indexPath, 'utf8');
953
- const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${name}`));
954
- if (prefixMatch) prefix = prefixMatch[1];
955
- }
956
-
957
- // Check if route imports a model
958
- const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
959
- let modelFields = [];
960
- if (modelImportMatch) {
961
- modelFields = getModelSchemaDetailed(modelImportMatch[2]).fields;
962
- }
963
-
964
- // Parse each endpoint with body/query fields
965
- const endpoints = [];
966
- const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*(?:auth\s*,\s*)?(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>\s*\{([\s\S]*?)(?=\nrouter\.|$)/gi;
967
- let m;
968
- while ((m = epRegex.exec(content)) !== null) {
969
- const method = m[1].toUpperCase();
970
- const epath = m[2];
971
- const body = m[3] || '';
972
-
973
- const bodyFields = [];
974
- const bodyMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.body/);
975
- if (bodyMatch) {
976
- bodyMatch[1].split(',').forEach(f => {
977
- const fname = f.trim();
978
- if (fname) {
979
- const meta = modelFields.find(mf => mf.name === fname);
980
- bodyFields.push({
981
- name: fname,
982
- type: meta?.type || 'String',
983
- required: meta?.required || false,
984
- unique: meta?.unique || false,
985
- });
986
- }
987
- });
988
- }
989
-
990
- const queryFields = [];
991
- const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
992
- if (queryMatch) {
993
- queryMatch[1].split(',').forEach(f => {
994
- const fname = f.trim();
995
- if (fname) queryFields.push({ name: fname, type: 'String' });
996
- });
997
- }
998
-
999
- // Check for params
1000
- const params = [];
1001
- const paramMatches = epath.match(/:(\w+)/g);
1002
- if (paramMatches) {
1003
- paramMatches.forEach(p => params.push(p.replace(':', '')));
1004
- }
1005
-
1006
- endpoints.push({ method, path: epath, fullPath: prefix + (epath === '/' ? '' : epath), bodyFields, queryFields, params });
1007
- }
1008
-
1009
- res.json({
1010
- name,
1011
- prefix,
1012
- routeFile,
1013
- modelName: modelImportMatch ? modelImportMatch[2] : null,
1014
- modelFields,
1015
- endpoints,
1016
- });
1017
- } catch (err) {
1018
- res.status(500).json({ error: err.message });
1019
- }
1020
- });
1021
-
1022
909
  // ---------------------------------------------------------------------------
1023
910
  // POST /api/crud/preview
1024
911
  router.post('/api/crud/preview', (req, res) => {
@@ -1161,30 +1048,13 @@ function writeIndexFile(content) {
1161
1048
  fs.writeFileSync(path.join(process.cwd(), 'index.js'), content, 'utf8');
1162
1049
  }
1163
1050
 
1051
+ // File-based wrappers around the shared pure index.js transforms in codegen.js.
1164
1052
  function addImportToIndex(importLine) {
1165
- let content = readIndexFile();
1166
- if (content.includes(importLine.trim())) return;
1167
- const lines = content.split('\n');
1168
- let lastImportIdx = -1;
1169
- lines.forEach((line, i) => {
1170
- if (line.startsWith('import ')) lastImportIdx = i;
1171
- });
1172
- lines.splice(lastImportIdx + 1, 0, importLine.trim());
1173
- writeIndexFile(lines.join('\n'));
1053
+ writeIndexFile(addImportAfterLastImport(readIndexFile(), importLine));
1174
1054
  }
1175
1055
 
1176
1056
  function insertBeforeListen(codeBlock) {
1177
- let content = readIndexFile();
1178
- const listenRegex = /(app|server)\.listen\(port/m;
1179
- let match = content.match(listenRegex);
1180
- if (!match) {
1181
- // Match the startServer() call, not the function declaration
1182
- match = content.match(/^startServer\(/m);
1183
- }
1184
- if (match) {
1185
- content = content.slice(0, match.index) + codeBlock.trim() + '\n' + content.slice(match.index);
1186
- }
1187
- writeIndexFile(content);
1057
+ writeIndexFile(insertBeforeListenContent(readIndexFile(), codeBlock));
1188
1058
  }
1189
1059
 
1190
1060
  function replaceAppListen(newBlock) {
@@ -1273,6 +1143,26 @@ function updateEnvFile(filePath, key, value) {
1273
1143
  }
1274
1144
  }
1275
1145
 
1146
+ // Record packages in package.json so the app stays installable even if the live
1147
+ // `npm install` below is offline/interrupted. Always call this BEFORE npm install.
1148
+ function recordDeps(names, dev = false) {
1149
+ try {
1150
+ const pkgPath = path.join(process.cwd(), 'package.json');
1151
+ if (!fs.existsSync(pkgPath)) return;
1152
+ const list = Array.isArray(names) ? names : [names];
1153
+ const { content, changed } = addDepsToPackageJson(fs.readFileSync(pkgPath, 'utf8'), list, { dev });
1154
+ if (changed) fs.writeFileSync(pkgPath, content, 'utf8');
1155
+ } catch {}
1156
+ }
1157
+
1158
+ // Record deps then run npm install (best-effort). Replaces bare execAsync('npm install …').
1159
+ async function installDeps(names, { dev = false } = {}) {
1160
+ const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
1161
+ if (list.length === 0) return;
1162
+ recordDeps(list, dev);
1163
+ try { await execAsync(`npm install ${dev ? '-D ' : ''}${list.join(' ')}`, { cwd: process.cwd() }); } catch {}
1164
+ }
1165
+
1276
1166
  // ---------------------------------------------------------------------------
1277
1167
  // GET /api/status
1278
1168
  // ---------------------------------------------------------------------------
@@ -1288,19 +1178,392 @@ router.get('/api/status', (req, res) => {
1288
1178
  const websocketConfigured = indexContent.includes('setupWebSocket') || indexContent.includes('WebSocketServer') || indexContent.includes("from 'ws'");
1289
1179
  const serialportConfigured = indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
1290
1180
 
1181
+ const mwExists = (f) => fs.existsSync(path.join(process.cwd(), 'src', 'middleware', f));
1182
+ const security = {
1183
+ helmet: indexContent.includes("from 'helmet'") || indexContent.includes('app.use(helmet'),
1184
+ rateLimit: indexContent.includes("from 'express-rate-limit'") || indexContent.includes('rateLimit('),
1185
+ errorHandler: mwExists('errorHandler.js'),
1186
+ roles: mwExists('roles.js'),
1187
+ apiKey: mwExists('apiKey.js'),
1188
+ validation: mwExists('validate.js'),
1189
+ };
1190
+
1191
+ // Folder name, sanitized into a valid default database name (Mongo db names
1192
+ // can't contain / \ . " $ * < > : | ? or spaces).
1193
+ const appName = (path.basename(process.cwd()) || 'app')
1194
+ .replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase() || 'app';
1195
+
1291
1196
  res.json({
1197
+ appName,
1292
1198
  mongoConfigured,
1293
1199
  routesDirExists,
1294
1200
  authMiddlewareExists,
1295
1201
  socketConfigured,
1296
1202
  websocketConfigured,
1297
- serialportConfigured
1203
+ serialportConfigured,
1204
+ security,
1205
+ aiConfigured: !!getActiveAiKey(),
1206
+ jobsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'jobs', 'scheduler.js')),
1207
+ docsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'docs.js')),
1208
+ deployConfigured: fs.existsSync(path.join(process.cwd(), 'Dockerfile')),
1209
+ mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js')),
1210
+ testsConfigured: fs.existsSync(path.join(process.cwd(), 'vitest.config.js'))
1298
1211
  });
1299
1212
  } catch (err) {
1300
1213
  res.status(500).json({ error: err.message });
1301
1214
  }
1302
1215
  });
1303
1216
 
1217
+ // ---------------------------------------------------------------------------
1218
+ // API Key management — named keys + metadata in src/api-keys.json. This file
1219
+ // ships with the app (unlike .4bnode), and the apiKey middleware reads it live,
1220
+ // so generate/revoke take effect without a restart. Writing a .json file is not
1221
+ // watched by nodemon, so no restart is triggered here either.
1222
+ // ---------------------------------------------------------------------------
1223
+ function apiKeysFilePath() {
1224
+ return path.join(process.cwd(), 'src', 'api-keys.json');
1225
+ }
1226
+ function readApiKeys() {
1227
+ try {
1228
+ const p = apiKeysFilePath();
1229
+ if (fs.existsSync(p)) {
1230
+ const list = JSON.parse(fs.readFileSync(p, 'utf8'));
1231
+ if (Array.isArray(list)) return list;
1232
+ }
1233
+ } catch {}
1234
+ return [];
1235
+ }
1236
+ function writeApiKeys(list) {
1237
+ const p = apiKeysFilePath();
1238
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1239
+ fs.writeFileSync(p, JSON.stringify(list, null, 2) + '\n', 'utf8');
1240
+ }
1241
+ function genApiKey(label) {
1242
+ return {
1243
+ id: crypto.randomBytes(8).toString('hex'),
1244
+ label: (String(label || '').trim() || 'Untitled').slice(0, 60),
1245
+ key: 'sk_' + crypto.randomBytes(24).toString('hex'),
1246
+ createdAt: new Date().toISOString(),
1247
+ };
1248
+ }
1249
+
1250
+ // GET/POST rate-limit config — stored in .env (read by index.js at startup).
1251
+ router.get('/api/security/rate-limit', (req, res) => {
1252
+ try {
1253
+ const envPath = path.join(process.cwd(), '.env');
1254
+ const windowMs = Number(getEnvValue(envPath, 'RATE_LIMIT_WINDOW_MS')) || 15 * 60 * 1000;
1255
+ const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 100;
1256
+ res.json({ windowMinutes: Math.max(1, Math.round(windowMs / 60000)), max });
1257
+ } catch (err) {
1258
+ res.status(500).json({ error: err.message });
1259
+ }
1260
+ });
1261
+ router.post('/api/security/rate-limit', (req, res) => {
1262
+ try {
1263
+ const envPath = path.join(process.cwd(), '.env');
1264
+ const windowMinutes = Math.max(1, Math.min(1440, Number(req.body && req.body.windowMinutes) || 15));
1265
+ const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 100));
1266
+ updateEnvFile(envPath, 'RATE_LIMIT_WINDOW_MS', String(windowMinutes * 60000));
1267
+ updateEnvFile(envPath, 'RATE_LIMIT_MAX', String(max));
1268
+ res.json({ message: 'Rate limit updated.', windowMinutes, max });
1269
+ } catch (err) {
1270
+ res.status(500).json({ error: err.message });
1271
+ }
1272
+ });
1273
+
1274
+ // GET — list keys + whether the apiKey middleware is configured
1275
+ router.get('/api/security/api-keys', (req, res) => {
1276
+ try {
1277
+ const configured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'apiKey.js'));
1278
+ res.json({ configured, keys: readApiKeys() });
1279
+ } catch (err) {
1280
+ res.status(500).json({ error: err.message });
1281
+ }
1282
+ });
1283
+
1284
+ // POST — generate a new named key
1285
+ router.post('/api/security/api-keys', (req, res) => {
1286
+ try {
1287
+ const list = readApiKeys();
1288
+ const key = genApiKey(req.body && req.body.label);
1289
+ list.push(key);
1290
+ writeApiKeys(list);
1291
+ res.json({ message: 'API key generated.', key });
1292
+ } catch (err) {
1293
+ res.status(500).json({ error: err.message });
1294
+ }
1295
+ });
1296
+
1297
+ // PATCH — rename a key
1298
+ router.patch('/api/security/api-keys/:id', (req, res) => {
1299
+ try {
1300
+ const list = readApiKeys();
1301
+ const key = list.find((k) => k.id === req.params.id);
1302
+ if (!key) return res.status(404).json({ error: 'Key not found.' });
1303
+ key.label = (String((req.body && req.body.label) || '').trim() || key.label).slice(0, 60);
1304
+ writeApiKeys(list);
1305
+ res.json({ message: 'API key renamed.', key });
1306
+ } catch (err) {
1307
+ res.status(500).json({ error: err.message });
1308
+ }
1309
+ });
1310
+
1311
+ // DELETE — revoke a key
1312
+ router.delete('/api/security/api-keys/:id', (req, res) => {
1313
+ try {
1314
+ const before = readApiKeys();
1315
+ const after = before.filter((k) => k.id !== req.params.id);
1316
+ if (after.length === before.length) return res.status(404).json({ error: 'Key not found.' });
1317
+ writeApiKeys(after);
1318
+ res.json({ message: 'API key revoked.' });
1319
+ } catch (err) {
1320
+ res.status(500).json({ error: err.message });
1321
+ }
1322
+ });
1323
+
1324
+ // ---------------------------------------------------------------------------
1325
+ // AI Builder — describe a backend in English, generate models + CRUD routes
1326
+ // ---------------------------------------------------------------------------
1327
+ // Currently selected provider (AI_PROVIDER in .env), validated against AI_PROVIDERS.
1328
+ function getAiProvider() {
1329
+ const fromFile = getEnvValue(path.join(process.cwd(), '.env'), 'AI_PROVIDER');
1330
+ const id = (fromFile && fromFile.trim()) || (process.env.AI_PROVIDER && process.env.AI_PROVIDER.trim()) || DEFAULT_AI_PROVIDER;
1331
+ return AI_PROVIDERS[id] ? id : DEFAULT_AI_PROVIDER;
1332
+ }
1333
+
1334
+ function getProviderKey(providerId) {
1335
+ const provider = AI_PROVIDERS[providerId];
1336
+ if (!provider) return '';
1337
+ const fromEnv = process.env[provider.keyEnv];
1338
+ if (fromEnv && fromEnv.trim()) return fromEnv.trim();
1339
+ return getEnvValue(path.join(process.cwd(), '.env'), provider.keyEnv);
1340
+ }
1341
+
1342
+ // Active provider's API key (used by the plan endpoint).
1343
+ function getActiveAiKey() {
1344
+ return getProviderKey(getAiProvider());
1345
+ }
1346
+
1347
+ // Effective model: explicit AI_MODEL override, else the active provider's default.
1348
+ function getAiModel(providerId) {
1349
+ const fromFile = getEnvValue(path.join(process.cwd(), '.env'), 'AI_MODEL');
1350
+ const override = (fromFile && fromFile.trim()) || (process.env.AI_MODEL && process.env.AI_MODEL.trim());
1351
+ return override || AI_PROVIDERS[providerId].defaultModel;
1352
+ }
1353
+
1354
+ // Fields for a route's model — from the plan if present, else from an existing model on disk.
1355
+ function fieldsForModel(modelName, plan) {
1356
+ const inPlan = (plan.models || []).find((m) => m.name === modelName);
1357
+ if (inPlan) return inPlan.fields.map((f) => f.name);
1358
+ return getModelSchema(modelName);
1359
+ }
1360
+
1361
+ // ── OpenAI: chat completions with a forced function call ──
1362
+ async function callOpenAIPlan(prompt, model, apiKey) {
1363
+ const resp = await fetch('https://api.openai.com/v1/chat/completions', {
1364
+ method: 'POST',
1365
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
1366
+ body: JSON.stringify({
1367
+ model,
1368
+ messages: [
1369
+ { role: 'system', content: AI_SYSTEM_PROMPT },
1370
+ { role: 'user', content: prompt },
1371
+ ],
1372
+ tools: [{ type: 'function', function: { name: 'emit_plan', description: 'Emit the backend plan to generate.', parameters: AI_PLAN_SCHEMA } }],
1373
+ tool_choice: { type: 'function', function: { name: 'emit_plan' } },
1374
+ }),
1375
+ });
1376
+ let data;
1377
+ try { data = await resp.json(); } catch { data = null; }
1378
+ if (!resp.ok) {
1379
+ const e = new Error((data && data.error && data.error.message) || `OpenAI API error (${resp.status})`);
1380
+ e.status = resp.status === 401 ? 401 : 502;
1381
+ throw e;
1382
+ }
1383
+ const toolCall = data?.choices?.[0]?.message?.tool_calls?.[0];
1384
+ if (!toolCall?.function?.arguments) throw new Error('The model did not return a structured plan. Try rephrasing.');
1385
+ try { return JSON.parse(toolCall.function.arguments); }
1386
+ catch { throw new Error('The model returned an unparseable plan. Try again.'); }
1387
+ }
1388
+
1389
+ // ── Anthropic: messages with a forced tool call ──
1390
+ async function callAnthropicPlan(prompt, model, apiKey) {
1391
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
1392
+ method: 'POST',
1393
+ headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
1394
+ body: JSON.stringify({
1395
+ model,
1396
+ max_tokens: 4096,
1397
+ system: AI_SYSTEM_PROMPT,
1398
+ tools: [{ name: 'emit_plan', description: 'Emit the backend plan to generate.', input_schema: AI_PLAN_SCHEMA }],
1399
+ tool_choice: { type: 'tool', name: 'emit_plan' },
1400
+ messages: [{ role: 'user', content: prompt }],
1401
+ }),
1402
+ });
1403
+ let data;
1404
+ try { data = await resp.json(); } catch { data = null; }
1405
+ if (!resp.ok) {
1406
+ const e = new Error((data && data.error && data.error.message) || `Anthropic API error (${resp.status})`);
1407
+ e.status = resp.status === 401 ? 401 : 502;
1408
+ throw e;
1409
+ }
1410
+ const toolUse = Array.isArray(data.content) ? data.content.find((c) => c.type === 'tool_use') : null;
1411
+ if (!toolUse) throw new Error('The model did not return a structured plan. Try rephrasing.');
1412
+ return toolUse.input;
1413
+ }
1414
+
1415
+ // Dispatch a plan request to the active provider.
1416
+ async function callAiPlan(prompt) {
1417
+ const providerId = getAiProvider();
1418
+ const apiKey = getProviderKey(providerId);
1419
+ if (!apiKey) {
1420
+ const e = new Error(`No ${AI_PROVIDERS[providerId].label} API key configured. Add it in AI Builder settings.`);
1421
+ e.status = 400;
1422
+ throw e;
1423
+ }
1424
+ const model = getAiModel(providerId);
1425
+ return providerId === 'anthropic'
1426
+ ? callAnthropicPlan(prompt, model, apiKey)
1427
+ : callOpenAIPlan(prompt, model, apiKey);
1428
+ }
1429
+
1430
+ // GET /api/ai/config — active provider, effective model, and per-provider status
1431
+ router.get('/api/ai/config', (req, res) => {
1432
+ try {
1433
+ const provider = getAiProvider();
1434
+ const providers = Object.values(AI_PROVIDERS).map((p) => ({
1435
+ id: p.id,
1436
+ label: p.label,
1437
+ keyPlaceholder: p.keyPlaceholder,
1438
+ keysUrl: p.keysUrl,
1439
+ defaultModel: p.defaultModel,
1440
+ configured: !!getProviderKey(p.id),
1441
+ }));
1442
+ res.json({ provider, model: getAiModel(provider), providers });
1443
+ } catch (err) {
1444
+ res.status(500).json({ error: err.message });
1445
+ }
1446
+ });
1447
+
1448
+ // POST /api/ai/key — save a provider's API key and make it the active provider
1449
+ router.post('/api/ai/key', (req, res) => {
1450
+ try {
1451
+ const providerId = (req.body && req.body.provider ? String(req.body.provider) : '').trim();
1452
+ const key = (req.body && req.body.key ? String(req.body.key) : '').trim();
1453
+ const provider = AI_PROVIDERS[providerId];
1454
+ if (!provider) return res.status(400).json({ error: 'Unknown provider.' });
1455
+ if (!key) return res.status(400).json({ error: 'API key is required.' });
1456
+
1457
+ const envPath = path.join(process.cwd(), '.env');
1458
+ updateEnvFile(envPath, provider.keyEnv, key);
1459
+ updateEnvFile(envPath, 'AI_PROVIDER', providerId);
1460
+ process.env[provider.keyEnv] = key; // usable immediately, no restart
1461
+ process.env.AI_PROVIDER = providerId;
1462
+ res.json({ message: `${provider.label} key saved and selected.`, provider: providerId });
1463
+ } catch (err) {
1464
+ res.status(500).json({ error: err.message });
1465
+ }
1466
+ });
1467
+
1468
+ // POST /api/ai/provider — switch the active provider (key must already exist)
1469
+ router.post('/api/ai/provider', (req, res) => {
1470
+ try {
1471
+ const providerId = (req.body && req.body.provider ? String(req.body.provider) : '').trim();
1472
+ const provider = AI_PROVIDERS[providerId];
1473
+ if (!provider) return res.status(400).json({ error: 'Unknown provider.' });
1474
+ if (!getProviderKey(providerId)) {
1475
+ return res.status(400).json({ error: `Add a ${provider.label} key first.` });
1476
+ }
1477
+ updateEnvFile(path.join(process.cwd(), '.env'), 'AI_PROVIDER', providerId);
1478
+ process.env.AI_PROVIDER = providerId;
1479
+ res.json({ message: `Switched to ${provider.label}.`, provider: providerId });
1480
+ } catch (err) {
1481
+ res.status(500).json({ error: err.message });
1482
+ }
1483
+ });
1484
+
1485
+ // POST /api/ai/plan — turn a prompt into a normalized plan + code previews
1486
+ router.post('/api/ai/plan', async (req, res) => {
1487
+ try {
1488
+ const prompt = (req.body && req.body.prompt ? String(req.body.prompt) : '').trim();
1489
+ if (!prompt) return res.status(400).json({ error: 'Describe what you want to build.' });
1490
+
1491
+ const raw = await callAiPlan(prompt);
1492
+ const plan = normalizeAiPlan(raw);
1493
+ if (plan.models.length === 0 && plan.routes.length === 0) {
1494
+ return res.status(422).json({ error: 'Could not derive a buildable plan. Try being more specific.' });
1495
+ }
1496
+
1497
+ const preview = {
1498
+ summary: plan.summary,
1499
+ models: plan.models.map((m) => ({
1500
+ name: m.name,
1501
+ fields: m.fields,
1502
+ code: generateMongooseModel(toPascalCase(m.name), m.fields),
1503
+ })),
1504
+ routes: plan.routes.map((r) => ({
1505
+ name: r.name,
1506
+ model: r.model,
1507
+ operations: r.operations,
1508
+ code: buildCrudRouteFile(r.name, r.model, fieldsForModel(r.model, plan), r.operations),
1509
+ })),
1510
+ };
1511
+ res.json({ plan, preview });
1512
+ } catch (err) {
1513
+ res.status(err.status || 500).json({ error: err.message });
1514
+ }
1515
+ });
1516
+
1517
+ // POST /api/ai/apply — generate the model + route files from an approved plan
1518
+ router.post('/api/ai/apply', async (req, res) => {
1519
+ try {
1520
+ const plan = normalizeAiPlan(req.body && req.body.plan ? req.body.plan : {});
1521
+ if (plan.models.length === 0 && plan.routes.length === 0) {
1522
+ return res.status(400).json({ error: 'Empty plan.' });
1523
+ }
1524
+ if (!checkMongoConfig()) {
1525
+ return res.status(400).json({ error: 'Configure MongoDB before generating models.' });
1526
+ }
1527
+
1528
+ const created = { models: [], routes: [], skipped: [] };
1529
+
1530
+ const modelsDir = path.join(process.cwd(), 'src', 'models');
1531
+ fs.mkdirSync(modelsDir, { recursive: true });
1532
+ for (const m of plan.models) {
1533
+ const file = path.join(modelsDir, m.name + '.js');
1534
+ if (fs.existsSync(file)) { created.skipped.push('model:' + m.name); continue; }
1535
+ fs.writeFileSync(file, generateMongooseModel(toPascalCase(m.name), m.fields), 'utf8');
1536
+ created.models.push(m.name);
1537
+ }
1538
+
1539
+ const routesDir = path.join(process.cwd(), 'src', 'routes');
1540
+ fs.mkdirSync(routesDir, { recursive: true });
1541
+ for (const r of plan.routes) {
1542
+ const file = path.join(routesDir, r.name + '.js');
1543
+ if (fs.existsSync(file)) { created.skipped.push('route:' + r.name); continue; }
1544
+ const fields = fieldsForModel(r.model, plan);
1545
+ fs.writeFileSync(file, buildCrudRouteFile(r.name, r.model, fields, r.operations), 'utf8');
1546
+ created.routes.push(r.name);
1547
+ const camel = toCamelCase(r.name);
1548
+ addImportToIndex(`import ${camel}Router from './src/routes/${r.name}.js';`);
1549
+ const reg = `app.use('/api/${r.name}', ${camel}Router);`;
1550
+ if (!readIndexFile().includes(reg)) insertBeforeListen(reg);
1551
+ }
1552
+
1553
+ const anyPassword = plan.routes.some((r) => fieldsForModel(r.model, plan).includes('password'));
1554
+ if (anyPassword) {
1555
+ await installDeps(['bcrypt']);
1556
+ }
1557
+
1558
+ res.json({
1559
+ message: `Generated ${created.models.length} model(s) and ${created.routes.length} route(s).`,
1560
+ ...created,
1561
+ });
1562
+ } catch (err) {
1563
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1564
+ }
1565
+ });
1566
+
1304
1567
  // ---------------------------------------------------------------------------
1305
1568
  // POST /api/add-api
1306
1569
  // ---------------------------------------------------------------------------
@@ -1334,6 +1597,7 @@ router.post('/api/add-api', async (req, res) => {
1334
1597
  const needsMulter = allFieldsCombined.some(f => f.type === 'File');
1335
1598
  if (needsMulter) packagesToInstall.push('multer');
1336
1599
  if (packagesToInstall.length > 0) {
1600
+ recordDeps(packagesToInstall);
1337
1601
  await execAsync(`npm install ${packagesToInstall.join(' ')}`, { cwd: process.cwd() });
1338
1602
  }
1339
1603
 
@@ -1645,12 +1909,27 @@ router.post('/', async (req, res) => {
1645
1909
  }
1646
1910
  }
1647
1911
 
1648
- const content = `${imports}
1912
+ let content = `${imports}
1649
1913
  const router = express.Router();
1650
1914
  ${handlers}
1651
1915
  export default router;
1652
1916
  `;
1653
1917
 
1918
+ // Per-route validation: generate a Zod schema from the fields THIS route
1919
+ // accepts (not the whole model — one model is used by many routes, each with a
1920
+ // different subset of fields). Named after the route (e.g. login → loginSchema),
1921
+ // it's wired into the route's write endpoints. File fields are skipped (they're
1922
+ // not in req.body). Types come from the model where known.
1923
+ const validatorFields = routeValidatorFields(type, {
1924
+ findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName,
1925
+ });
1926
+ if (validatorFields && validatorFields.length) {
1927
+ const validatorsDir = path.join(process.cwd(), 'src', 'validators');
1928
+ fs.mkdirSync(validatorsDir, { recursive: true });
1929
+ fs.writeFileSync(path.join(validatorsDir, safeName + '.js'), buildZodSchemaFile(safeName, validatorFields), 'utf8');
1930
+ content = wireValidateIntoRouteContent(content, safeName);
1931
+ }
1932
+
1654
1933
  fs.writeFileSync(routeFilePath, content, 'utf8');
1655
1934
 
1656
1935
  const label = type === 'methods' ? methodList.map(m => m.toUpperCase()).join(', ') : type === 'crud' ? 'CRUD (' + (methodList.length > 0 ? methodList.map(m => m.toUpperCase()).join(', ') : 'ALL') + ')' : type === 'plain' ? 'PLAIN' : type.toUpperCase();
@@ -1683,6 +1962,7 @@ export default router;
1683
1962
  if (!indexContent.includes(routeRegistration)) {
1684
1963
  insertBeforeListen(routeRegistration);
1685
1964
  }
1965
+ refreshDocsSpec();
1686
1966
  } catch (err) {
1687
1967
  if (!res.headersSent) res.status(500).json({ error: err.message });
1688
1968
  }
@@ -1712,6 +1992,7 @@ router.post('/api/add-mongo', async (req, res) => {
1712
1992
  }
1713
1993
 
1714
1994
  // First-time setup: install mongoose, create db.js, modify index.js
1995
+ recordDeps(['mongoose']);
1715
1996
  await execAsync('npm install mongoose', { cwd: process.cwd() });
1716
1997
 
1717
1998
  const dbDir = path.join(process.cwd(), 'src');
@@ -1768,6 +2049,7 @@ router.post('/api/add-login', async (req, res) => {
1768
2049
 
1769
2050
  // Install packages FIRST before modifying files
1770
2051
  const packages = hasPassword ? 'jsonwebtoken bcrypt' : 'jsonwebtoken';
2052
+ recordDeps(String(packages).split(/\s+/).filter(Boolean));
1771
2053
  await execAsync(`npm install ${packages}`, { cwd: process.cwd() });
1772
2054
 
1773
2055
  // Create src/middleware/auth.js if it doesn't exist
@@ -1919,6 +2201,7 @@ router.post('/api/add-socket', async (req, res) => {
1919
2201
  const currentContent = readIndexFile();
1920
2202
  const hasWebSocket = currentContent.includes('setupWebSocket') || currentContent.includes('WebSocketServer');
1921
2203
 
2204
+ recordDeps(['socket.io']);
1922
2205
  if (hasWebSocket) {
1923
2206
  try { await execAsync('npm uninstall ws && npm install socket.io', { cwd: process.cwd() }); } catch {
1924
2207
  try { await execAsync('npm install socket.io', { cwd: process.cwd() }); } catch {}
@@ -2035,6 +2318,7 @@ router.post('/api/add-websocket', async (req, res) => {
2035
2318
  const currentContent = readIndexFile();
2036
2319
  const hasSocketIO = currentContent.includes('setupSocketIO') || currentContent.includes('socket.io');
2037
2320
 
2321
+ recordDeps(['ws']);
2038
2322
  if (hasSocketIO) {
2039
2323
  try { await execAsync('npm uninstall socket.io && npm install ws', { cwd: process.cwd() }); } catch {
2040
2324
  try { await execAsync('npm install ws', { cwd: process.cwd() }); } catch {}
@@ -2186,6 +2470,7 @@ router.post('/api/add-serialport', async (req, res) => {
2186
2470
  // Install FIRST before modifying index.js
2187
2471
  const indexContent = readIndexFile();
2188
2472
  if (!indexContent.includes("from 'serialport'")) {
2473
+ recordDeps(['serialport', '@serialport/parser-readline']);
2189
2474
  await execAsync('npm install serialport @serialport/parser-readline', { cwd: process.cwd() });
2190
2475
  }
2191
2476
 
@@ -2450,23 +2735,13 @@ router.get('/api/api-tester/routes', (req, res) => {
2450
2735
  const bodyFields = [];
2451
2736
  const queryFields = [];
2452
2737
  const fileFields = [];
2453
- const bodyMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.body/);
2454
- if (bodyMatch) {
2455
- bodyMatch[1].split(',').forEach(f => {
2456
- const fname = f.trim();
2457
- if (fname) {
2458
- const meta = modelFields.find(mf => mf.name === fname);
2459
- bodyFields.push({ name: fname, type: meta?.type || 'String', required: meta?.required || false, unique: meta?.unique || false });
2460
- }
2461
- });
2462
- }
2463
- const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
2464
- if (queryMatch) {
2465
- queryMatch[1].split(',').forEach(f => {
2466
- const fname = f.trim();
2467
- if (fname) queryFields.push({ name: fname, type: 'String' });
2468
- });
2469
- }
2738
+ extractRequestFields(body, 'body').forEach(fname => {
2739
+ const meta = modelFields.find(mf => mf.name === fname);
2740
+ bodyFields.push({ name: fname, type: meta?.type || 'String', required: meta?.required || false, unique: meta?.unique || false });
2741
+ });
2742
+ extractRequestFields(body, 'query').forEach(fname => {
2743
+ queryFields.push({ name: fname, type: 'String' });
2744
+ });
2470
2745
  // Detect file fields from multer middleware and req.file/req.files
2471
2746
  const uploadSingleMatch = body.match(/upload\.single\(\s*['"](\w+)['"]\s*\)/);
2472
2747
  if (uploadSingleMatch) fileFields.push({ name: uploadSingleMatch[1] });
@@ -2513,381 +2788,6 @@ router.get('/api/api-tester/routes', (req, res) => {
2513
2788
  }
2514
2789
  });
2515
2790
 
2516
- // ---------------------------------------------------------------------------
2517
- // Reports — schema info & Excel generation
2518
- // ---------------------------------------------------------------------------
2519
- router.get('/api/reports/schema-info', (req, res) => {
2520
- try {
2521
- const modelFiles = listModels();
2522
- const models = modelFiles.map(f => {
2523
- const name = path.basename(f, '.js');
2524
- const detail = getModelSchemaDetailed(name);
2525
- const fields = Array.isArray(detail) ? detail : detail.fields;
2526
- return { name, fields: fields.map(field => {
2527
- const result = { ...field };
2528
- if (field.type === 'ObjectId' && field.ref) {
2529
- const refDetail = getModelSchemaDetailed(field.ref);
2530
- const refFields = Array.isArray(refDetail) ? refDetail : refDetail.fields;
2531
- result.refFields = refFields.map(rf => rf.name);
2532
- }
2533
- return result;
2534
- })};
2535
- });
2536
- res.json({ models });
2537
- } catch (err) {
2538
- res.status(500).json({ error: err.message });
2539
- }
2540
- });
2541
-
2542
- // Date formatting helper
2543
- function formatDateValue(val, format, tz) {
2544
- if (!(val instanceof Date)) {
2545
- try { val = new Date(val); } catch { return String(val); }
2546
- if (isNaN(val.getTime())) return String(val);
2547
- }
2548
- if (format === 'ISO') return val.toISOString();
2549
- if (format === 'UNIX') return Math.floor(val.getTime() / 1000);
2550
- const isAmPm = format.includes(' A');
2551
- const opts = { timeZone: tz || 'UTC', hour12: isAmPm };
2552
- const parts = {};
2553
- try {
2554
- const fmt = new Intl.DateTimeFormat('en-US', { ...opts, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
2555
- fmt.formatToParts(val).forEach(p => { parts[p.type] = p.value; });
2556
- } catch { return val.toISOString(); }
2557
- const Y = parts.year, M = parts.month, D = parts.day;
2558
- const H = parts.hour, m = parts.minute, s = parts.second;
2559
- const ampm = parts.dayPeriod || '';
2560
- const time = isAmPm ? `${H}:${m}:${s} ${ampm}` : `${H}:${m}:${s}`;
2561
- // Normalize hour for 24h (en-US with hour12:false gives "24" for midnight, fix to "00")
2562
- const H24 = !isAmPm && H === '24' ? '00' : H;
2563
- const time24 = `${H24}:${m}:${s}`;
2564
- switch (format) {
2565
- case 'YYYY-MM-DD HH:mm:ss': return `${Y}-${M}-${D} ${time24}`;
2566
- case 'DD/MM/YYYY HH:mm:ss': return `${D}/${M}/${Y} ${time24}`;
2567
- case 'MM/DD/YYYY HH:mm:ss': return `${M}/${D}/${Y} ${time24}`;
2568
- case 'YYYY-MM-DD hh:mm:ss A': return `${Y}-${M}-${D} ${time}`;
2569
- case 'DD/MM/YYYY hh:mm:ss A': return `${D}/${M}/${Y} ${time}`;
2570
- case 'MM/DD/YYYY hh:mm:ss A': return `${M}/${D}/${Y} ${time}`;
2571
- case 'YYYY-MM-DD': return `${Y}-${M}-${D}`;
2572
- case 'DD/MM/YYYY': return `${D}/${M}/${Y}`;
2573
- case 'MM/DD/YYYY': return `${M}/${D}/${Y}`;
2574
- default: return `${Y}-${M}-${D} ${time24}`;
2575
- }
2576
- }
2577
-
2578
- function formatVal(val, dateFormat, timezone) {
2579
- if (val === null || val === undefined) return null;
2580
- // Native Date
2581
- if (val instanceof Date) return formatDateValue(val, dateFormat, timezone);
2582
- // String that looks like ISO date
2583
- if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(val)) return formatDateValue(val, dateFormat, timezone);
2584
- // Non-null objects (BSON types from MongoDB driver)
2585
- if (typeof val === 'object') {
2586
- const str = val.toString();
2587
- // BSON Date — toString gives a date string, or has getTime
2588
- if (typeof val.getTime === 'function') return formatDateValue(new Date(val.getTime()), dateFormat, timezone);
2589
- // ObjectId or other BSON — toString gives hex string
2590
- if (str && str !== '[object Object]') return str;
2591
- return JSON.stringify(val);
2592
- }
2593
- return val;
2594
- }
2595
-
2596
- router.post('/api/reports/preview', async (req, res) => {
2597
- try {
2598
- const { name, fields = [], systemFields = [], populatedFields = {}, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC', fieldPrefixes = {} } = req.body;
2599
- if (!name) return res.status(400).json({ error: 'Model name required' });
2600
-
2601
- const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
2602
- if (!fs.existsSync(projectMongoose)) return res.status(400).json({ error: 'Mongoose not installed.' });
2603
- const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
2604
- if (mongoose.connection.readyState === 0) {
2605
- const uri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
2606
- if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
2607
- await mongoose.connect(uri);
2608
- }
2609
-
2610
- const collection = mongoose.connection.db.collection(name + 's');
2611
- const rawDocs = await collection.find({}).limit(10).toArray();
2612
- // Convert BSON to plain JSON objects for reliable field access
2613
- const docs = JSON.parse(JSON.stringify(rawDocs));
2614
-
2615
- // Populate referenced fields
2616
- const lookups = {};
2617
- for (const [refField, subFields] of Object.entries(populatedFields)) {
2618
- if (!subFields || subFields.length === 0) continue;
2619
- const detail = getModelSchemaDetailed(name);
2620
- const schemaFields = detail.fields || [];
2621
- const fieldDef = schemaFields.find(f => f.name === refField);
2622
- if (!fieldDef || !fieldDef.ref) continue;
2623
- const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id.toString()) : String(id)))];
2624
- if (ids.length === 0) continue;
2625
- const { ObjectId } = mongoose.Types;
2626
- const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
2627
- .find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
2628
- const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
2629
- const map = {};
2630
- for (const rd of refDocs) {
2631
- const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
2632
- map[String(rdId)] = rd;
2633
- }
2634
- lookups[refField] = { subFields, map };
2635
- }
2636
-
2637
- function applyPrefix(key, val) {
2638
- if (val === null || val === undefined) return val;
2639
- const prefix = fieldPrefixes[key];
2640
- return prefix ? prefix + val : val;
2641
- }
2642
-
2643
- const rows = docs.map(doc => {
2644
- const row = {};
2645
- // System fields first
2646
- for (const sf of systemFields) {
2647
- let val = doc[sf];
2648
- if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2649
- if (val && typeof val === 'object' && val.$date) val = val.$date;
2650
- row[sf] = applyPrefix(sf, formatVal(val, dateFormat, timezone));
2651
- }
2652
- // Model fields
2653
- for (const f of fields) {
2654
- let val = doc[f];
2655
- if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2656
- if (val && typeof val === 'object' && val.$date) val = val.$date;
2657
- row[f] = applyPrefix(f, formatVal(val, dateFormat, timezone));
2658
- }
2659
- // Populated ref fields
2660
- for (const [refField, { subFields, map }] of Object.entries(lookups)) {
2661
- let refId = doc[refField];
2662
- if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
2663
- const refDoc = refId ? map[String(refId)] : null;
2664
- for (const sf of subFields) {
2665
- const colKey = refField + '.' + sf;
2666
- let val = refDoc ? refDoc[sf] : null;
2667
- if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2668
- if (val && typeof val === 'object' && val.$date) val = val.$date;
2669
- row[colKey] = applyPrefix(colKey, formatVal(val, dateFormat, timezone));
2670
- }
2671
- }
2672
- return row;
2673
- });
2674
-
2675
- // Reorder columns if columnOrder provided
2676
- const columnOrder = req.body.columnOrder;
2677
- if (columnOrder && columnOrder.length > 0) {
2678
- const ordered = rows.map(row => {
2679
- const r = {};
2680
- columnOrder.forEach(col => { if (col in row) r[col] = row[col]; });
2681
- return r;
2682
- });
2683
- return res.json({ rows: ordered });
2684
- }
2685
- res.json({ rows });
2686
- } catch (err) {
2687
- res.status(500).json({ error: err.message });
2688
- }
2689
- });
2690
-
2691
- router.post('/api/reports/generate', async (req, res) => {
2692
- try {
2693
- const { models: reportModels, filename, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC' } = req.body;
2694
- if (!reportModels || !reportModels.length) {
2695
- return res.status(400).json({ error: 'No models selected' });
2696
- }
2697
-
2698
- // Try to import xlsx-js-style (supports styling) or fall back to xlsx
2699
- let XLSX;
2700
- try {
2701
- const styledPkg = path.join(process.cwd(), 'node_modules', 'xlsx-js-style');
2702
- const plainPkg = path.join(process.cwd(), 'node_modules', 'xlsx');
2703
- if (!fs.existsSync(styledPkg)) {
2704
- await execAsync('npm install xlsx-js-style', { cwd: process.cwd() });
2705
- }
2706
- // Use createRequire for CJS packages
2707
- const { createRequire } = await import('module');
2708
- const require = createRequire(process.cwd() + '/');
2709
- XLSX = fs.existsSync(styledPkg) ? require('xlsx-js-style') : require('xlsx');
2710
- } catch (e) {
2711
- return res.status(400).json({ error: 'Failed to load xlsx. Run: npm install xlsx-js-style' });
2712
- }
2713
-
2714
- // Connect to MongoDB
2715
- const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
2716
- if (!fs.existsSync(projectMongoose)) {
2717
- return res.status(400).json({ error: 'Mongoose is not installed.' });
2718
- }
2719
- const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
2720
- if (mongoose.connection.readyState === 0) {
2721
- const uri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
2722
- if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
2723
- await mongoose.connect(uri);
2724
- }
2725
-
2726
- const wb = XLSX.utils.book_new();
2727
-
2728
- for (const rm of reportModels) {
2729
- const collectionName = rm.name + 's';
2730
- const collection = mongoose.connection.db.collection(collectionName);
2731
- const rowLimit = req.body.limit > 0 ? Math.min(req.body.limit, 50000) : 10000;
2732
- const rawDocs = await collection.find({}).limit(rowLimit).toArray();
2733
- const docs = JSON.parse(JSON.stringify(rawDocs));
2734
-
2735
- // Build lookup maps for populated fields
2736
- const lookups = {};
2737
- if (rm.populatedFields) {
2738
- for (const [refField, subFields] of Object.entries(rm.populatedFields)) {
2739
- if (!subFields || subFields.length === 0) continue;
2740
- const detail = getModelSchemaDetailed(rm.name);
2741
- const schemaFields = detail.fields || [];
2742
- const fieldDef = schemaFields.find(f => f.name === refField);
2743
- if (!fieldDef || !fieldDef.ref) continue;
2744
-
2745
- const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id) : String(id)))];
2746
- if (ids.length === 0) continue;
2747
-
2748
- const { ObjectId } = mongoose.Types;
2749
- const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
2750
- .find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
2751
- const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
2752
-
2753
- const map = {};
2754
- for (const rd of refDocs) {
2755
- const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
2756
- map[String(rdId)] = rd;
2757
- }
2758
- lookups[refField] = { subFields, map };
2759
- }
2760
- }
2761
-
2762
- // Build rows
2763
- const sysFields = rm.systemFields || [];
2764
- const rmPrefixes = rm.fieldPrefixes || {};
2765
- function applyRmPrefix(key, val) {
2766
- if (val === null || val === undefined || val === '') return val;
2767
- const prefix = rmPrefixes[key];
2768
- return prefix ? prefix + val : val;
2769
- }
2770
- const rows = docs.map(doc => {
2771
- const row = {};
2772
- // System fields first
2773
- for (const sf of sysFields) {
2774
- let val = doc[sf];
2775
- if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2776
- if (val && typeof val === 'object' && val.$date) val = val.$date;
2777
- const fv = formatVal(val, dateFormat, timezone);
2778
- row[sf] = applyRmPrefix(sf, fv === null ? '' : fv);
2779
- }
2780
- // Model fields
2781
- for (const field of rm.fields) {
2782
- let val = doc[field];
2783
- if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2784
- if (val && typeof val === 'object' && val.$date) val = val.$date;
2785
- const fv = formatVal(val, dateFormat, timezone);
2786
- row[field] = applyRmPrefix(field, fv === null ? '' : fv);
2787
- }
2788
- // Populated ref fields
2789
- for (const [refField, { subFields, map }] of Object.entries(lookups)) {
2790
- let refId = doc[refField];
2791
- if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
2792
- const refDoc = refId ? map[String(refId)] : null;
2793
- for (const sf of subFields) {
2794
- const colKey = refField + '.' + sf;
2795
- let val = refDoc ? refDoc[sf] : null;
2796
- if (val && typeof val === 'object' && val.$oid) val = val.$oid;
2797
- if (val && typeof val === 'object' && val.$date) val = val.$date;
2798
- const fv = formatVal(val, dateFormat, timezone);
2799
- row[colKey] = applyRmPrefix(colKey, fv === null ? '' : fv);
2800
- }
2801
- }
2802
- return row;
2803
- });
2804
-
2805
- // Reorder columns if columnOrder provided
2806
- const colOrder = rm.columnOrder;
2807
- const headerLabels = rm.headerLabels || {};
2808
- let finalRows = rows;
2809
- if (colOrder && colOrder.length > 0) {
2810
- finalRows = rows.map(row => {
2811
- const r = {};
2812
- colOrder.forEach(col => {
2813
- const label = headerLabels[col] || col;
2814
- if (col in row) r[label] = row[col];
2815
- });
2816
- return r;
2817
- });
2818
- } else if (Object.keys(headerLabels).length > 0) {
2819
- finalRows = rows.map(row => {
2820
- const r = {};
2821
- Object.keys(row).forEach(col => {
2822
- const label = headerLabels[col] || col;
2823
- r[label] = row[col];
2824
- });
2825
- return r;
2826
- });
2827
- }
2828
-
2829
- // Build headers with custom labels
2830
- const rawHeaders = colOrder && colOrder.length > 0 ? colOrder : Object.keys(rows[0] || {});
2831
- const displayHeaders = rawHeaders.map(h => headerLabels[h] || h);
2832
-
2833
- // Create worksheet with explicit column order
2834
- const ws = XLSX.utils.json_to_sheet(finalRows, { header: displayHeaders });
2835
-
2836
- // Professional styling
2837
- const range = XLSX.utils.decode_range(ws['!ref'] || 'A1');
2838
- // Style header row — bold with background
2839
- const thinBorder = { style: "thin", color: { rgb: "B4C6E7" } };
2840
- const allBorders = { top: thinBorder, bottom: thinBorder, left: thinBorder, right: thinBorder };
2841
- const headerBorder = { top: { style: "thin", color: { rgb: "2F5597" } }, bottom: { style: "thin", color: { rgb: "2F5597" } }, left: { style: "thin", color: { rgb: "2F5597" } }, right: { style: "thin", color: { rgb: "2F5597" } } };
2842
- // Style header row
2843
- for (let C = range.s.c; C <= range.e.c; C++) {
2844
- const addr = XLSX.utils.encode_cell({ r: 0, c: C });
2845
- if (!ws[addr]) continue;
2846
- ws[addr].s = {
2847
- font: { bold: true, color: { rgb: "FFFFFF" }, sz: 12, name: "Calibri" },
2848
- fill: { fgColor: { rgb: "4472C4" } },
2849
- alignment: { horizontal: "center", vertical: "center" },
2850
- border: headerBorder,
2851
- };
2852
- }
2853
- // Alternate row colors with full borders
2854
- for (let R = 1; R <= range.e.r; R++) {
2855
- const isEven = R % 2 === 0;
2856
- for (let C = range.s.c; C <= range.e.c; C++) {
2857
- const addr = XLSX.utils.encode_cell({ r: R, c: C });
2858
- if (!ws[addr]) ws[addr] = { v: '', t: 's' };
2859
- ws[addr].s = {
2860
- fill: isEven ? { fgColor: { rgb: "D6E4F0" } } : { fgColor: { rgb: "FFFFFF" } },
2861
- font: { sz: 11, color: { rgb: "333333" }, name: "Calibri" },
2862
- alignment: { vertical: "center" },
2863
- border: allBorders,
2864
- };
2865
- }
2866
- }
2867
- // Column widths with breathing room
2868
- ws['!cols'] = displayHeaders.map((key, i) => {
2869
- const rawKey = rawHeaders[i];
2870
- const maxLen = Math.max(key.length, ...finalRows.map(r => String(r[key] || '').length).slice(0, 100));
2871
- return { wch: maxLen + 4 };
2872
- });
2873
- // Row heights — uniform for all rows
2874
- const totalRows = range.e.r + 1;
2875
- ws['!rows'] = Array.from({ length: totalRows }, () => ({ hpt: 26 }));
2876
-
2877
- const sheetName = rm.name.length > 31 ? rm.name.slice(0, 31) : rm.name;
2878
- XLSX.utils.book_append_sheet(wb, ws, sheetName);
2879
- }
2880
-
2881
- const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
2882
- const fname = (filename || 'report') + '.xlsx';
2883
- res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
2884
- res.setHeader('Content-Disposition', 'attachment; filename="' + fname + '"');
2885
- res.send(Buffer.from(buf));
2886
- } catch (err) {
2887
- res.status(500).json({ error: err.message });
2888
- }
2889
- });
2890
-
2891
2791
  // ---------------------------------------------------------------------------
2892
2792
  // Logs — history, SSE stream, clear
2893
2793
  // ---------------------------------------------------------------------------
@@ -2899,8 +2799,14 @@ router.get('/api/logs/stream', (req, res) => {
2899
2799
  // EventSource can't send custom headers, so verify passkey from query param
2900
2800
  const stored = getPasskeyHash();
2901
2801
  if (stored) {
2802
+ const lock = authLockState(req);
2803
+ if (lock.locked) {
2804
+ res.set('Retry-After', String(lock.retryAfter));
2805
+ return res.status(429).json({ error: 'Too many attempts. Try again later.' });
2806
+ }
2902
2807
  const pk = req.query.passkey;
2903
- if (!pk || hashPasskey(pk) !== stored) {
2808
+ if (!verifyPasskey(pk, stored)) {
2809
+ if (pk) recordAuthFailure(req);
2904
2810
  return res.status(401).json({ error: 'Unauthorized' });
2905
2811
  }
2906
2812
  }