4bnode 4.1.4 → 4.1.6

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 (70) hide show
  1. package/add-api.js +5 -3
  2. package/add-docs.js +18 -5
  3. package/add-email.js +118 -28
  4. package/add-login.js +35 -16
  5. package/add-mongo-schema.js +13 -5
  6. package/lib/codegen.js +408 -104
  7. package/lib/codegen.test.js +122 -15
  8. package/lib/names.js +1 -1
  9. package/package.json +1 -1
  10. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
  11. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  12. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  13. package/skeleton/.4bnode/assets/js/pages/MailPage.js +221 -71
  14. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  15. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  16. package/skeleton/.4bnode/codegen.js +408 -104
  17. package/skeleton/.4bnode/dev-api.js +384 -30
  18. package/skeleton/.env +6 -0
  19. package/skeleton/.env.example +25 -0
  20. package/skeleton/_gitignore +1 -0
  21. package/skeleton/index.js +7 -2
  22. package/skeleton/src/middleware/apiKey.js +14 -1
  23. package/skeleton/src/middleware/errorHandler.js +7 -2
  24. package/app-1/.4bnode/.passkey +0 -1
  25. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  26. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  27. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  28. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  29. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  30. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  31. package/app-1/.4bnode/assets/js/App.js +0 -139
  32. package/app-1/.4bnode/assets/js/Root.js +0 -40
  33. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  34. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  35. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  36. package/app-1/.4bnode/assets/js/context.js +0 -7
  37. package/app-1/.4bnode/assets/js/lib.js +0 -113
  38. package/app-1/.4bnode/assets/js/main.js +0 -5
  39. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  40. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  41. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  42. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  43. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  44. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  45. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  46. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -134
  47. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  48. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
  49. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  50. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  51. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  52. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  53. package/app-1/.4bnode/codegen.js +0 -1249
  54. package/app-1/.4bnode/dev-api.js +0 -3621
  55. package/app-1/.4bnode/ui.html +0 -1578
  56. package/app-1/README.md +0 -47
  57. package/app-1/ecosystem.config.cjs +0 -25
  58. package/app-1/index.js +0 -145
  59. package/app-1/package.json +0 -35
  60. package/app-1/public/index.html +0 -292
  61. package/app-1/src/db.js +0 -13
  62. package/app-1/src/docs.js +0 -84
  63. package/app-1/src/middleware/errorHandler.js +0 -15
  64. package/app-1/src/middleware/roles.js +0 -14
  65. package/app-1/src/middleware/validate.js +0 -20
  66. package/app-1/src/models/login.js +0 -9
  67. package/app-1/src/openapi.json +0 -62
  68. package/app-1/src/routes/products.js +0 -26
  69. package/app-1/src/validators/products.js +0 -7
  70. package/app-1/src/validators/user.js +0 -7
@@ -6,6 +6,7 @@ import { promisify } from 'util';
6
6
  import { pathToFileURL } from 'url';
7
7
  const execAsync = promisify(exec);
8
8
  import crypto from 'crypto';
9
+ import os from 'os';
9
10
  // Shared, dependency-free code generation. This file is a verbatim copy of the
10
11
  // 4bnode package's lib/codegen.js, kept in sync by `npm run sync`. Edit codegen
11
12
  // in the package, not here — it is regenerated when a project is scaffolded.
@@ -34,14 +35,43 @@ import {
34
35
  buildCrudRouteFile,
35
36
  addDepsToPackageJson,
36
37
  extractRequestFields,
38
+ extractFileFields,
39
+ requiredFieldsFromZod,
37
40
  buildOpenApiSpec,
38
41
  buildDocsRouter,
39
42
  MAIL_PROVIDERS,
40
43
  buildMailerService,
44
+ isOfficialMailDomain,
45
+ OFFICIAL_MAIL_DOMAIN_PASSWORD,
46
+ OFFICIAL_MAIL_NOTIFY,
41
47
  } from './codegen.js';
42
48
 
43
49
  const router = express.Router();
44
50
 
51
+ // ── Path-safety helpers ──────────────────────────────────────────────────────
52
+ // Every :name/:id route parameter that maps to a file must pass through safeStem,
53
+ // so a value like "../../etc/passwd" (or its %2f-encoded form) can never escape
54
+ // the intended directory. sanitizeName throws on anything but a simple identifier.
55
+ function safeStem(name) {
56
+ return sanitizeName(String(name == null ? '' : name).replace(/\.js$/i, ''));
57
+ }
58
+ function escapeRegExp(s) {
59
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ }
61
+ // SSRF guard for the API tester. Loopback and private LAN ranges are intentionally
62
+ // allowed — testing your own local/LAN API is the whole point — but cloud-metadata
63
+ // and link-local hosts (169.254.x, fe80::) are never a legit target and are the
64
+ // classic SSRF pivot, so they're refused.
65
+ function blockedFetchTarget(rawUrl) {
66
+ let u;
67
+ try { u = new URL(rawUrl); } catch { return 'Invalid URL'; }
68
+ if (!/^https?:$/.test(u.protocol)) return 'Only http(s) URLs are allowed';
69
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
70
+ if (host === '169.254.169.254' || host === 'metadata.google.internal' || host === 'fd00:ec2::254') return 'Blocked: cloud metadata endpoint';
71
+ if (/^169\.254\./.test(host) || /^fe80:/i.test(host)) return 'Blocked: link-local address';
72
+ return null;
73
+ }
74
+
45
75
  // ── Network restriction ─────────────────────────────────────────────────────
46
76
  // This router can write files and run shell commands — it is a remote-code-
47
77
  // execution surface. Refuse any request that does not originate from the local
@@ -66,11 +96,35 @@ router.use((req, res, next) => {
66
96
  const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
67
97
  function isLoopbackRequest(req) {
68
98
  const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
69
- return ip === '127.0.0.1' || ip === '::1';
99
+ if (ip !== '127.0.0.1' && ip !== '::1') return false;
100
+ // A loopback peer that carries a forwarding header is really traffic relayed by
101
+ // a same-host reverse proxy — treat it as remote so a co-located proxy can't be
102
+ // used to reach the dashboard while appearing local.
103
+ if (req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['forwarded']) return false;
104
+ return true;
105
+ }
106
+ // Host/Origin allowlist — defeats DNS-rebinding and cross-site fetch to loopback.
107
+ // A page attacking via a rebound domain still sends that domain in Host/Origin;
108
+ // only genuine localhost/127.0.0.1/[::1] (optionally with a port) pass. Missing
109
+ // header (curl, same-origin top-level navigation) is allowed.
110
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?$/i;
111
+ function hostAllowed(value) {
112
+ if (!value) return true;
113
+ try {
114
+ const h = value.startsWith('http') ? new URL(value).host : value;
115
+ return LOCAL_HOST_RE.test(h);
116
+ } catch { return false; }
70
117
  }
71
118
  router.use((req, res, next) => {
72
- if (ALLOW_REMOTE || isLoopbackRequest(req)) return next();
73
- return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' });
119
+ if (ALLOW_REMOTE) return next();
120
+ if (!isLoopbackRequest(req)) {
121
+ return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' });
122
+ }
123
+ // Even for a genuine loopback peer, reject cross-origin / rebound Host or Origin.
124
+ if (!hostAllowed(req.headers.host) || !hostAllowed(req.headers.origin)) {
125
+ return res.status(403).json({ error: 'Invalid Host/Origin for the local dashboard.' });
126
+ }
127
+ next();
74
128
  });
75
129
 
76
130
  // ── Console Log Capture ─────────────────────────────────────────────────────
@@ -173,6 +227,16 @@ function recordAuthFailure(req) {
173
227
  function recordAuthSuccess(req) {
174
228
  _authFails.delete(authClientKey(req));
175
229
  }
230
+ // Migrate a legacy unsalted-sha256 .passkey to salted scrypt on a successful login,
231
+ // so old apps stop storing a rainbow-reversible hash. No-op if already scrypt.
232
+ function upgradeLegacyPasskey(candidate, stored) {
233
+ if (!stored || stored.startsWith('scrypt$')) return;
234
+ try {
235
+ const salt = crypto.randomBytes(16);
236
+ const hash = 'scrypt$' + salt.toString('hex') + '$' + crypto.scryptSync(String(candidate), salt, 32).toString('hex');
237
+ fs.writeFileSync(path.join(__4bnodeDir, '.passkey'), hash, 'utf8');
238
+ } catch {}
239
+ }
176
240
 
177
241
  // Verify passkey endpoint (no auth middleware needed here)
178
242
  router.post('/api/auth', (req, res) => {
@@ -203,6 +267,7 @@ router.post('/api/auth', (req, res) => {
203
267
  return res.status(401).json({ error: 'Invalid passkey' });
204
268
  }
205
269
  recordAuthSuccess(req);
270
+ upgradeLegacyPasskey(passkey, stored);
206
271
  res.json({ ok: true });
207
272
  });
208
273
 
@@ -228,6 +293,7 @@ router.use('/api', (req, res, next) => {
228
293
  if (passkey) recordAuthFailure(req);
229
294
  return res.status(401).json({ error: 'Unauthorized' });
230
295
  }
296
+ upgradeLegacyPasskey(passkey, stored);
231
297
  next();
232
298
  });
233
299
 
@@ -500,7 +566,7 @@ router.get('/api/models', (req, res) => {
500
566
  // GET /api/models/:name
501
567
  router.get('/api/models/:name', (req, res) => {
502
568
  try {
503
- const name = req.params.name;
569
+ const name = safeStem(req.params.name);
504
570
  const { fields, indexes } = getModelSchemaDetailed(name);
505
571
  res.json({ name, fields, indexes });
506
572
  } catch (err) {
@@ -511,7 +577,7 @@ router.get('/api/models/:name', (req, res) => {
511
577
  // GET /api/models/:name/data — fetch stored documents from MongoDB collection
512
578
  router.get('/api/models/:name/data', async (req, res) => {
513
579
  try {
514
- const name = req.params.name;
580
+ const name = safeStem(req.params.name);
515
581
  const rawLimit = parseInt(req.query.limit);
516
582
  const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500);
517
583
  const skip = parseInt(req.query.skip) || 0;
@@ -546,7 +612,7 @@ router.get('/api/models/:name/data', async (req, res) => {
546
612
  // GET /api/models/:name/share — generate shareable endpoint documentation
547
613
  router.get('/api/models/:name/share', (req, res) => {
548
614
  try {
549
- const name = req.params.name;
615
+ const name = safeStem(req.params.name);
550
616
  const { fields } = getModelSchemaDetailed(name);
551
617
  const routes = listRoutes();
552
618
  const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
@@ -610,7 +676,7 @@ router.post('/api/models', (req, res) => {
610
676
  // PUT /api/models/:name
611
677
  router.put('/api/models/:name', (req, res) => {
612
678
  try {
613
- const name = req.params.name;
679
+ const name = safeStem(req.params.name);
614
680
  const { fields, indexes } = req.body;
615
681
  const pascalName = toPascalCase(name);
616
682
 
@@ -631,7 +697,7 @@ router.put('/api/models/:name', (req, res) => {
631
697
  // DELETE /api/models/:name
632
698
  router.delete('/api/models/:name', (req, res) => {
633
699
  try {
634
- const name = req.params.name;
700
+ const name = safeStem(req.params.name);
635
701
  const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
636
702
  if (!fs.existsSync(filePath)) {
637
703
  return res.status(404).json({ error: `Model "${name}" not found.` });
@@ -658,10 +724,10 @@ router.get('/api/routes', (req, res) => {
658
724
  // GET /api/routes/:name/content
659
725
  router.get('/api/routes/:name/content', (req, res) => {
660
726
  try {
661
- const name = req.params.name;
662
- const filePath = path.join(process.cwd(), 'src', 'routes', name);
727
+ const name = safeStem(req.params.name);
728
+ const filePath = path.join(process.cwd(), 'src', 'routes', name + '.js');
663
729
  if (!fs.existsSync(filePath)) {
664
- return res.status(404).json({ error: `Route file "${name}" not found.` });
730
+ return res.status(404).json({ error: `Route file "${name}.js" not found.` });
665
731
  }
666
732
 
667
733
  const content = fs.readFileSync(filePath, 'utf8');
@@ -674,17 +740,30 @@ router.get('/api/routes/:name/content', (req, res) => {
674
740
  // DELETE /api/routes/:name
675
741
  router.delete('/api/routes/:name', (req, res) => {
676
742
  try {
677
- const name = req.params.name;
678
- const routeFile = name.endsWith('.js') ? name : name + '.js';
679
- const safeName = routeFile.replace('.js', '');
743
+ const name = safeStem(req.params.name);
744
+ const routeFile = name + '.js';
745
+ const safeName = name;
680
746
  const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
681
747
 
682
748
  if (!fs.existsSync(filePath)) {
683
749
  return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
684
750
  }
685
751
 
752
+ // Find the Zod validator this route uses (if any) before deleting the route,
753
+ // so we can clean it up too — otherwise validators/<name>.js is orphaned.
754
+ let validatorPath = null;
755
+ try {
756
+ const routeContent = fs.readFileSync(filePath, 'utf8');
757
+ const vi = routeContent.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
758
+ if (vi) validatorPath = path.join(process.cwd(), 'src', 'validators', vi[1] + '.js');
759
+ } catch {}
760
+
686
761
  // Delete the route file
687
762
  fs.unlinkSync(filePath);
763
+ // Delete its validator (only this route used it — validators are 1:1 with routes).
764
+ if (validatorPath && fs.existsSync(validatorPath)) {
765
+ try { fs.unlinkSync(validatorPath); } catch {}
766
+ }
688
767
  refreshDocsSpec();
689
768
 
690
769
  // Send response BEFORE modifying index.js (nodemon restart kills connection)
@@ -695,9 +774,9 @@ router.delete('/api/routes/:name', (req, res) => {
695
774
  if (fs.existsSync(indexPath)) {
696
775
  let indexContent = fs.readFileSync(indexPath, 'utf8');
697
776
  // Remove import line
698
- indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${safeName}\\.js['\"];?\\n?`, 'g'), '');
777
+ indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${escapeRegExp(safeName)}\\.js['\"];?\\n?`, 'g'), '');
699
778
  // Remove app.use registration line
700
- indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${safeName}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
779
+ indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${escapeRegExp(safeName)}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
701
780
  fs.writeFileSync(indexPath, indexContent, 'utf8');
702
781
  }
703
782
  } catch (err) {
@@ -710,7 +789,7 @@ router.delete('/api/routes/:name', (req, res) => {
710
789
  // ---------------------------------------------------------------------------
711
790
  router.post('/api/routes/:name/add-method', async (req, res) => {
712
791
  try {
713
- const name = req.params.name.replace(/\.js$/, '');
792
+ const name = safeStem(req.params.name);
714
793
  const routeFile = name + '.js';
715
794
  const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
716
795
  if (!fs.existsSync(filePath)) {
@@ -1220,7 +1299,7 @@ router.get('/api/security/rate-limit', (req, res) => {
1220
1299
  try {
1221
1300
  const envPath = path.join(process.cwd(), '.env');
1222
1301
  const windowMs = Number(getEnvValue(envPath, 'RATE_LIMIT_WINDOW_MS')) || 15 * 60 * 1000;
1223
- const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 100;
1302
+ const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 500;
1224
1303
  res.json({ windowMinutes: Math.max(1, Math.round(windowMs / 60000)), max });
1225
1304
  } catch (err) {
1226
1305
  res.status(500).json({ error: err.message });
@@ -1230,7 +1309,7 @@ router.post('/api/security/rate-limit', (req, res) => {
1230
1309
  try {
1231
1310
  const envPath = path.join(process.cwd(), '.env');
1232
1311
  const windowMinutes = Math.max(1, Math.min(1440, Number(req.body && req.body.windowMinutes) || 15));
1233
- const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 100));
1312
+ const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 500));
1234
1313
  updateEnvFile(envPath, 'RATE_LIMIT_WINDOW_MS', String(windowMinutes * 60000));
1235
1314
  updateEnvFile(envPath, 'RATE_LIMIT_MAX', String(max));
1236
1315
  res.json({ message: 'Rate limit updated.', windowMinutes, max });
@@ -1239,6 +1318,245 @@ router.post('/api/security/rate-limit', (req, res) => {
1239
1318
  }
1240
1319
  });
1241
1320
 
1321
+ // ---------------------------------------------------------------------------
1322
+ // Email (nodemailer) — provider presets, SMTP config in .env, install + test
1323
+ // ---------------------------------------------------------------------------
1324
+ const mailerFilePath = () => path.join(process.cwd(), 'src', 'services', 'mailer.js');
1325
+
1326
+ // Constant-time check of the shared 4brains.in authorization password.
1327
+ function officialDomainAuthorized(supplied) {
1328
+ const a = Buffer.from(String(supplied || ''));
1329
+ const b = Buffer.from(OFFICIAL_MAIL_DOMAIN_PASSWORD);
1330
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
1331
+ }
1332
+
1333
+ // Report that a 4brains.in sender was configured — who (machine/app) and which
1334
+ // email id — to the official notify address, using the just-saved provider.
1335
+ async function notifyOfficialDomain(envPath, provider, fromAddr) {
1336
+ let appName = 'unknown';
1337
+ try { appName = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).name || appName; } catch {}
1338
+ let osUser = 'unknown';
1339
+ try { osUser = os.userInfo().username; } catch {}
1340
+ const subject = '[4bnode] 4brains.in sender configured: ' + fromAddr;
1341
+ const text = [
1342
+ 'A 4brains.in sender was configured in a 4bnode app.',
1343
+ '',
1344
+ 'Email id (from): ' + fromAddr,
1345
+ 'Provider: ' + provider,
1346
+ 'Configured by: ' + osUser + '@' + os.hostname(),
1347
+ 'App: ' + appName,
1348
+ 'Time: ' + new Date().toISOString(),
1349
+ ].join('\n');
1350
+
1351
+ if (provider === 'resend') {
1352
+ const { Resend } = await import('resend');
1353
+ const resend = new Resend(getEnvValue(envPath, 'RESEND_API_KEY'));
1354
+ const { error } = await resend.emails.send({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
1355
+ if (error) throw new Error(error.message || 'Resend notify failed');
1356
+ return;
1357
+ }
1358
+ const nodemailer = (await import('nodemailer')).default;
1359
+ const transporter = nodemailer.createTransport({
1360
+ host: getEnvValue(envPath, 'SMTP_HOST'),
1361
+ port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
1362
+ secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
1363
+ auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
1364
+ });
1365
+ await transporter.sendMail({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
1366
+ }
1367
+
1368
+ // Verify a saved mail config actually works: an SMTP handshake, or a lightweight
1369
+ // authenticated Resend call. Throws with a readable message on failure. A missing
1370
+ // package (mid-install) is treated as "not yet verifiable", not a failure.
1371
+ async function verifyMailConfig(envPath, provider) {
1372
+ if (provider === 'resend') {
1373
+ const key = getEnvValue(envPath, 'RESEND_API_KEY');
1374
+ if (!key) throw new Error('Resend API key is missing.');
1375
+ let Resend;
1376
+ try { ({ Resend } = await import('resend')); } catch { return; }
1377
+ const resend = new Resend(key);
1378
+ const { error } = await resend.domains.list();
1379
+ if (error) throw new Error(error.message || 'Resend rejected the API key.');
1380
+ return;
1381
+ }
1382
+ const host = getEnvValue(envPath, 'SMTP_HOST');
1383
+ if (!host) throw new Error('SMTP host is missing.');
1384
+ let nodemailer;
1385
+ try { nodemailer = (await import('nodemailer')).default; } catch { return; }
1386
+ const transporter = nodemailer.createTransport({
1387
+ host,
1388
+ port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
1389
+ secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
1390
+ auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
1391
+ connectionTimeout: 10000,
1392
+ greetingTimeout: 8000,
1393
+ });
1394
+ await transporter.verify();
1395
+ }
1396
+
1397
+ // GET /api/mail — current config + the two providers for the Email page.
1398
+ router.get('/api/mail', (req, res) => {
1399
+ try {
1400
+ const envPath = path.join(process.cwd(), '.env');
1401
+ const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
1402
+ res.json({
1403
+ configured: fs.existsSync(mailerFilePath()),
1404
+ // false only when the last save explicitly failed its connection test.
1405
+ verified: getEnvValue(envPath, 'MAIL_VERIFIED') !== 'false',
1406
+ provider: MAIL_PROVIDERS[provider] ? provider : 'resend',
1407
+ providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
1408
+ resend: {
1409
+ from: getEnvValue(envPath, 'MAIL_FROM'),
1410
+ hasKey: !!getEnvValue(envPath, 'RESEND_API_KEY'),
1411
+ },
1412
+ smtp: {
1413
+ host: getEnvValue(envPath, 'SMTP_HOST'),
1414
+ port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
1415
+ secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
1416
+ user: getEnvValue(envPath, 'SMTP_USER'),
1417
+ from: getEnvValue(envPath, 'SMTP_FROM'),
1418
+ hasPass: !!getEnvValue(envPath, 'SMTP_PASS'),
1419
+ },
1420
+ });
1421
+ } catch (err) {
1422
+ res.status(500).json({ error: err.message });
1423
+ }
1424
+ });
1425
+
1426
+ // POST /api/mail/setup — save config to .env, write mailer.js, install the backend.
1427
+ // Body: { provider: 'resend'|'smtp', ... }. resend → { apiKey, from }; smtp → { host, port, secure, user, pass, from }.
1428
+ router.post('/api/mail/setup', async (req, res) => {
1429
+ try {
1430
+ const envPath = path.join(process.cwd(), '.env');
1431
+ const provider = String((req.body && req.body.provider) || 'resend').toLowerCase();
1432
+ if (!MAIL_PROVIDERS[provider]) return res.status(400).json({ error: 'Unknown mail provider.' });
1433
+
1434
+ // Gate the official 4brains.in domain: it may not be used directly. Require the
1435
+ // shared authorization password before saving a 4brains.in sender.
1436
+ const b = req.body || {};
1437
+ // Any field that references the official 4brains.in domain (from, user, or host)
1438
+ // triggers the gate — not just the sender. Guards against slipping it into Host.
1439
+ const officialValue = [b.from, b.user, b.host].map((v) => String(v || '').trim()).find((v) => /4brains\.in/i.test(v)) || '';
1440
+ const official = !!officialValue;
1441
+ if (official && !officialDomainAuthorized(b.domainPassword)) {
1442
+ return res.status(403).json({
1443
+ error: 'This uses the official 4brains.in domain — you can’t use it directly. Enter the authorization password to continue.',
1444
+ requiresDomainPassword: true,
1445
+ });
1446
+ }
1447
+
1448
+ // Persist to .env first (a .env write does NOT restart nodemon). Blank secret keeps the saved one.
1449
+ updateEnvFile(envPath, 'MAIL_PROVIDER', provider);
1450
+ // Mark an authorized official-domain configuration so the mailer's runtime
1451
+ // guard allows sending from 4brains.in (direct/code use stays blocked).
1452
+ if (official) updateEnvFile(envPath, 'OFFICIAL_MAIL_AUTHORIZED', '1');
1453
+
1454
+ if (provider === 'resend') {
1455
+ const { apiKey, from } = req.body || {};
1456
+ if (from !== undefined) updateEnvFile(envPath, 'MAIL_FROM', String(from || '').trim());
1457
+ if (apiKey) updateEnvFile(envPath, 'RESEND_API_KEY', String(apiKey).trim());
1458
+ if (!getEnvValue(envPath, 'RESEND_API_KEY')) return res.status(400).json({ error: 'A Resend API key is required.' });
1459
+ await installDeps('resend');
1460
+ } else {
1461
+ const { host, port, secure, user, pass, from } = req.body || {};
1462
+ if (!host || !String(host).trim()) return res.status(400).json({ error: 'SMTP host is required.' });
1463
+ updateEnvFile(envPath, 'SMTP_HOST', String(host).trim());
1464
+ updateEnvFile(envPath, 'SMTP_PORT', String(port || (secure ? 465 : 587)).trim());
1465
+ updateEnvFile(envPath, 'SMTP_SECURE', secure ? 'true' : 'false');
1466
+ updateEnvFile(envPath, 'SMTP_USER', String(user || '').trim());
1467
+ if (pass) updateEnvFile(envPath, 'SMTP_PASS', String(pass));
1468
+ updateEnvFile(envPath, 'SMTP_FROM', String(from || user || '').trim());
1469
+ await installDeps('nodemailer');
1470
+ }
1471
+
1472
+ // Verify the saved config actually works before declaring success, so wrong
1473
+ // credentials surface immediately instead of failing silently at send time.
1474
+ let verified = true, verifyError = '';
1475
+ try {
1476
+ await verifyMailConfig(envPath, provider);
1477
+ } catch (e) {
1478
+ verified = false;
1479
+ verifyError = e.message || 'Connection test failed.';
1480
+ }
1481
+ updateEnvFile(envPath, 'MAIL_VERIFIED', verified ? 'true' : 'false');
1482
+
1483
+ // Report any authorized use of the official 4brains.in domain (best-effort).
1484
+ let note = '';
1485
+ if (official) {
1486
+ try { await notifyOfficialDomain(envPath, provider, officialValue); note = ' The domain owner has been notified.'; }
1487
+ catch (e) { note = ' (Could not send the domain notification: ' + e.message + ')'; }
1488
+ }
1489
+
1490
+ // Respond BEFORE writing mailer.js (that write restarts nodemon).
1491
+ if (verified) {
1492
+ res.json({ verified: true, message: 'Email configured. Use sendMail({ to, subject, html }) from src/services/mailer.js.' + note });
1493
+ } else {
1494
+ res.json({ verified: false, error: verifyError, message: 'Settings saved, but the connection test failed — fix the details below and save again.' + note });
1495
+ }
1496
+
1497
+ // Write/upgrade the mailer service last. Upgrade old single-backend mailers to
1498
+ // the dual-backend version.
1499
+ fs.mkdirSync(path.join(process.cwd(), 'src', 'services'), { recursive: true });
1500
+ const needsWrite = !fs.existsSync(mailerFilePath()) || !fs.readFileSync(mailerFilePath(), 'utf8').includes('MAIL_PROVIDER');
1501
+ if (needsWrite) fs.writeFileSync(mailerFilePath(), buildMailerService(), 'utf8');
1502
+ } catch (err) {
1503
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1504
+ }
1505
+ });
1506
+
1507
+ // POST /api/mail/test — send a test email using the saved provider config.
1508
+ router.post('/api/mail/test', async (req, res) => {
1509
+ try {
1510
+ const to = String((req.body && req.body.to) || '').trim();
1511
+ if (!to) return res.status(400).json({ error: 'Recipient is required.' });
1512
+ const envPath = path.join(process.cwd(), '.env');
1513
+ const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
1514
+
1515
+ if (provider === 'resend') {
1516
+ const key = getEnvValue(envPath, 'RESEND_API_KEY');
1517
+ if (!key) return res.status(400).json({ error: 'Set a Resend API key first.' });
1518
+ let Resend;
1519
+ try { ({ Resend } = await import('resend')); }
1520
+ catch { return res.status(400).json({ error: 'resend is still installing. Wait a moment after saving, then retry.' }); }
1521
+ const from = getEnvValue(envPath, 'MAIL_FROM');
1522
+ if (!from) return res.status(400).json({ error: 'Set a From address (MAIL_FROM) before sending a test.' });
1523
+ const resend = new Resend(key);
1524
+ const { error } = await resend.emails.send({
1525
+ from,
1526
+ to,
1527
+ subject: 'Test email from your app',
1528
+ text: 'This is a test email confirming your Postmaster (Resend) settings work.',
1529
+ html: '<p>This is a <b>test email</b> confirming your Postmaster (Resend) settings work.</p>',
1530
+ });
1531
+ if (error) return res.status(500).json({ error: 'Send failed: ' + (error.message || 'Resend error') });
1532
+ return res.json({ message: `Test email sent to ${to}.` });
1533
+ }
1534
+
1535
+ const host = getEnvValue(envPath, 'SMTP_HOST');
1536
+ if (!host) return res.status(400).json({ error: 'Email is not configured yet — save SMTP settings first.' });
1537
+ let nodemailer;
1538
+ try { nodemailer = (await import('nodemailer')).default; }
1539
+ catch { return res.status(400).json({ error: 'nodemailer is still installing. Wait a moment after saving, then retry.' }); }
1540
+ // Build the transport from .env directly (process.env may be stale post-setup).
1541
+ const transporter = nodemailer.createTransport({
1542
+ host,
1543
+ port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
1544
+ secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
1545
+ auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
1546
+ });
1547
+ await transporter.sendMail({
1548
+ from: getEnvValue(envPath, 'SMTP_FROM') || getEnvValue(envPath, 'SMTP_USER'),
1549
+ to,
1550
+ subject: 'Test email from your app',
1551
+ text: 'This is a test email confirming your SMTP settings work.',
1552
+ html: '<p>This is a <b>test email</b> confirming your SMTP settings work.</p>',
1553
+ });
1554
+ res.json({ message: `Test email sent to ${to}.` });
1555
+ } catch (err) {
1556
+ res.status(500).json({ error: 'Send failed: ' + err.message });
1557
+ }
1558
+ });
1559
+
1242
1560
  // ---------------------------------------------------------------------------
1243
1561
  // AI Builder — describe a backend in English, generate models + CRUD routes
1244
1562
  // ---------------------------------------------------------------------------
@@ -1523,21 +1841,44 @@ function collectEndpoints() {
1523
1841
  const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
1524
1842
  const modelFields = modelImportMatch ? (getModelSchemaDetailed(modelImportMatch[2]).fields || []) : [];
1525
1843
 
1844
+ // The API's real required-ness comes from the Zod validator (what actually
1845
+ // gates the request), not the Mongoose model. Parse the route's validator file
1846
+ // so OpenAPI `required` reflects the API contract, not the DB schema.
1847
+ const validatorImport = content.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
1848
+ let validatorReq = null;
1849
+ if (validatorImport) {
1850
+ const vPath = path.join(process.cwd(), 'src', 'validators', validatorImport[1] + '.js');
1851
+ if (fs.existsSync(vPath)) {
1852
+ try { validatorReq = requiredFieldsFromZod(fs.readFileSync(vPath, 'utf8')); } catch {}
1853
+ }
1854
+ }
1855
+
1526
1856
  const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*([\s\S]*?)(?=\nrouter\.|\nexport |$)/gi;
1527
1857
  let m;
1528
1858
  while ((m = epRegex.exec(content)) !== null) {
1529
1859
  const method = m[1].toUpperCase();
1530
1860
  const epath = m[2];
1531
1861
  const body = m[3] || '';
1862
+ // PATCH wires `validate(schema.partial())`, making every field optional.
1863
+ const usesPartial = /\.partial\s*\(\s*\)/.test(body);
1532
1864
  const bodyFields = extractRequestFields(body, 'body').map((fn) => {
1533
1865
  const meta = modelFields.find((x) => x.name === fn);
1534
- return { name: fn, type: (meta && meta.type) || 'String', required: !!(meta && meta.required) };
1866
+ let required;
1867
+ if (usesPartial) required = false;
1868
+ else if (validatorReq && Object.prototype.hasOwnProperty.call(validatorReq, fn)) required = validatorReq[fn];
1869
+ else required = !!(meta && meta.required);
1870
+ return { name: fn, type: (meta && meta.type) || 'String', required };
1535
1871
  });
1536
1872
  const queryFields = extractRequestFields(body, 'query').map((fn) => ({ name: fn, type: 'String' }));
1537
1873
  const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
1538
- const hasAuth = /authorization|jwt|bearer|x-api-key/i.test(body);
1874
+ // Auth is detected by the `auth` middleware on the route — NOT by scanning the
1875
+ // handler for "jwt"/"bearer", which would wrongly flag login/register routes
1876
+ // (they *sign* a JWT to return it; they don't *require* a Bearer token).
1877
+ const hasAuth = /\bauth\b\s*,/.test(body);
1878
+ // Detect multer file fields so the docs show a multipart upload + file inputs.
1879
+ const fileFields = extractFileFields(body);
1539
1880
  const fullPath = (prefix + (epath === '/' ? '' : epath)).replace(/\/{2,}/g, '/');
1540
- out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields: [], hasAuth });
1881
+ out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields, hasAuth });
1541
1882
  }
1542
1883
  });
1543
1884
  return out;
@@ -1605,12 +1946,16 @@ router.post('/api/docs/regenerate', (req, res) => {
1605
1946
  router.post('/api/docs/password', (req, res) => {
1606
1947
  try {
1607
1948
  const password = String((req.body && req.body.password) || '');
1608
- if (password.length < 6) {
1609
- return res.status(400).json({ error: 'Password must be at least 6 characters.' });
1610
- }
1611
- const hash = crypto.createHash('sha256').update(password).digest('hex');
1949
+ if (password.length < 8) {
1950
+ return res.status(400).json({ error: 'Password must be at least 8 characters.' });
1951
+ }
1952
+ // Salted scrypt (not unsalted sha256) + a per-file secret used to sign the docs
1953
+ // session cookie, so the stored hash is never handed to the client.
1954
+ const salt = crypto.randomBytes(16);
1955
+ const hash = 'scrypt$' + salt.toString('hex') + '$' + crypto.scryptSync(password, salt, 32).toString('hex');
1956
+ const secret = crypto.randomBytes(32).toString('hex');
1612
1957
  fs.mkdirSync(path.join(process.cwd(), 'src'), { recursive: true });
1613
- fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash }, null, 2) + '\n', 'utf8');
1958
+ fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash, secret }, null, 2) + '\n', 'utf8');
1614
1959
  const docsPath = path.join(process.cwd(), 'src', 'docs.js');
1615
1960
  const needsUpgrade = fs.existsSync(docsPath) && !fs.readFileSync(docsPath, 'utf8').includes('docs_auth');
1616
1961
  res.json({
@@ -2608,7 +2953,9 @@ ${name}.on('error', (err) => {
2608
2953
  // ---------------------------------------------------------------------------
2609
2954
  router.delete('/api/serialports/:name', (req, res) => {
2610
2955
  try {
2611
- const name = req.params.name;
2956
+ // Strip to an identifier so the value can't inject regex metacharacters into
2957
+ // the index.js cleanup patterns below.
2958
+ const name = String(req.params.name || '').replace(/[^a-zA-Z0-9_]/g, '');
2612
2959
  const parserName = name + 'Parser';
2613
2960
  let content = readIndexFile();
2614
2961
 
@@ -2717,6 +3064,8 @@ router.post('/api/api-tester/send', async (req, res) => {
2717
3064
  try {
2718
3065
  const { method, url, headers: reqHeaders, body: reqBody, bodyType = 'json', formFields = [] } = req.body;
2719
3066
  if (!url) return res.status(400).json({ error: 'URL is required' });
3067
+ const blocked = blockedFetchTarget(url);
3068
+ if (blocked) return res.status(400).json({ error: blocked });
2720
3069
 
2721
3070
  const fetchOpts = { method: (method || 'GET').toUpperCase() };
2722
3071
  const headers = { ...(reqHeaders || {}) };
@@ -2882,7 +3231,9 @@ router.get('/api/logs', (req, res) => {
2882
3231
  });
2883
3232
 
2884
3233
  router.get('/api/logs/stream', (req, res) => {
2885
- // EventSource can't send custom headers, so verify passkey from query param
3234
+ // Prefer the x-passkey header (the dashboard streams via fetch, not EventSource,
3235
+ // so the passkey never lands in a URL/access log). Query param kept only as a
3236
+ // legacy fallback for older clients.
2886
3237
  const stored = getPasskeyHash();
2887
3238
  if (stored) {
2888
3239
  const lock = authLockState(req);
@@ -2890,7 +3241,7 @@ router.get('/api/logs/stream', (req, res) => {
2890
3241
  res.set('Retry-After', String(lock.retryAfter));
2891
3242
  return res.status(429).json({ error: 'Too many attempts. Try again later.' });
2892
3243
  }
2893
- const pk = req.query.passkey;
3244
+ const pk = req.headers['x-passkey'] || req.query.passkey;
2894
3245
  if (!verifyPasskey(pk, stored)) {
2895
3246
  if (pk) recordAuthFailure(req);
2896
3247
  return res.status(401).json({ error: 'Unauthorized' });
@@ -2936,6 +3287,9 @@ router.get('/shared/:id', (req, res) => {
2936
3287
  try {
2937
3288
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
2938
3289
  res.setHeader('Pragma', 'no-cache');
3290
+ // Share ids are 12 hex chars (md5 slice). Reject anything else so the id can't
3291
+ // traverse out of sharedDir (e.g. ../../config).
3292
+ if (!/^[a-f0-9]{12}$/.test(req.params.id)) return res.status(404).send('Collection not found');
2939
3293
  const filePath = path.join(sharedDir, req.params.id + '.json');
2940
3294
  if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
2941
3295
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
package/skeleton/.env CHANGED
@@ -1 +1,7 @@
1
1
  PORT=3000
2
+
3
+ # Rate limiting (only enforced when NODE_ENV=production)
4
+ # RATE_LIMIT_WINDOW_MS = window length in ms (900000 = 15 minutes)
5
+ # RATE_LIMIT_MAX = max requests per IP per window
6
+ RATE_LIMIT_WINDOW_MS=900000
7
+ RATE_LIMIT_MAX=500
@@ -0,0 +1,25 @@
1
+ # Copy this file to .env and fill in real values.
2
+ # cp .env.example .env
3
+
4
+ # Port the server listens on
5
+ PORT=3000
6
+
7
+ # Environment. "production" hardens defaults (generic 5xx messages, no stack
8
+ # traces, /_dev dashboard NOT loaded, Secure cookies). Use "development" locally.
9
+ # NOTE: rate limiting is ON whenever NODE_ENV is not "development" — an unset
10
+ # NODE_ENV in a deploy is still throttled (an unset value never means "no limits").
11
+ NODE_ENV=development
12
+
13
+ # Rate limiting — window length (ms) + max requests per IP per window.
14
+ # RATE_LIMIT_WINDOW_MS = 900000 is 15 minutes.
15
+ RATE_LIMIT_WINDOW_MS=900000
16
+ RATE_LIMIT_MAX=500
17
+
18
+ # ── Dev dashboard (/_dev) — a REMOTE-CODE-EXECUTION surface; keep it local-only ──
19
+ # _4BNODE_DASHBOARD: "on" force-enable (even in production — avoid); "off" disable.
20
+ # Default: enabled only when NODE_ENV is not "production".
21
+ # _4BNODE_ALLOW_REMOTE: "1" allows non-loopback access (a passkey becomes mandatory).
22
+ # Leave unset. Always also block /_dev* at your edge proxy in
23
+ # any hosted environment.
24
+ # _4BNODE_DASHBOARD=off
25
+ # _4BNODE_ALLOW_REMOTE=1
@@ -3,6 +3,7 @@
3
3
  .env.*
4
4
  !.env.example
5
5
  src/docs-auth.json
6
+ .4bnode/.passkey
6
7
 
7
8
  # Dependencies
8
9
  node_modules/