4bnode 4.1.5 → 4.1.7

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 (68) hide show
  1. package/add-api.js +5 -3
  2. package/add-email.js +118 -28
  3. package/add-login.js +35 -16
  4. package/add-mongo-schema.js +13 -5
  5. package/lib/codegen.js +191 -57
  6. package/lib/codegen.test.js +87 -15
  7. package/lib/names.js +1 -1
  8. package/package.json +1 -1
  9. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  10. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  11. package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
  12. package/skeleton/.4bnode/codegen.js +191 -57
  13. package/skeleton/.4bnode/dev-api.js +284 -56
  14. package/skeleton/.env.example +17 -3
  15. package/skeleton/_gitignore +1 -0
  16. package/skeleton/index.js +27 -4
  17. package/skeleton/src/middleware/apiKey.js +14 -1
  18. package/skeleton/src/middleware/errorHandler.js +7 -2
  19. package/app-1/.4bnode/.passkey +0 -1
  20. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  21. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  22. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  23. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  24. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  25. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  26. package/app-1/.4bnode/assets/js/App.js +0 -139
  27. package/app-1/.4bnode/assets/js/Root.js +0 -40
  28. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  29. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  30. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  31. package/app-1/.4bnode/assets/js/context.js +0 -7
  32. package/app-1/.4bnode/assets/js/lib.js +0 -113
  33. package/app-1/.4bnode/assets/js/main.js +0 -5
  34. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  35. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  36. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  37. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  38. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  39. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  40. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  41. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
  42. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  43. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
  44. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  45. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  46. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  47. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  48. package/app-1/.4bnode/codegen.js +0 -1440
  49. package/app-1/.4bnode/dev-api.js +0 -3747
  50. package/app-1/.4bnode/ui.html +0 -1578
  51. package/app-1/.env.example +0 -11
  52. package/app-1/README.md +0 -47
  53. package/app-1/ecosystem.config.cjs +0 -25
  54. package/app-1/index.js +0 -188
  55. package/app-1/package.json +0 -37
  56. package/app-1/public/index.html +0 -292
  57. package/app-1/src/db.js +0 -13
  58. package/app-1/src/docs.js +0 -84
  59. package/app-1/src/middleware/apiKey.js +0 -36
  60. package/app-1/src/middleware/auth.js +0 -17
  61. package/app-1/src/middleware/errorHandler.js +0 -15
  62. package/app-1/src/middleware/roles.js +0 -14
  63. package/app-1/src/middleware/validate.js +0 -20
  64. package/app-1/src/models/user.js +0 -11
  65. package/app-1/src/openapi.json +0 -80
  66. package/app-1/src/routes/upload-resume.js +0 -21
  67. package/app-1/src/services/mailer.js +0 -52
  68. package/app-1/src/validators/upload-resume.js +0 -8
@@ -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.
@@ -40,10 +41,37 @@ import {
40
41
  buildDocsRouter,
41
42
  MAIL_PROVIDERS,
42
43
  buildMailerService,
44
+ isOfficialMailDomain,
45
+ OFFICIAL_MAIL_DOMAIN_PASSWORD,
46
+ OFFICIAL_MAIL_NOTIFY,
43
47
  } from './codegen.js';
44
48
 
45
49
  const router = express.Router();
46
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
+
47
75
  // ── Network restriction ─────────────────────────────────────────────────────
48
76
  // This router can write files and run shell commands — it is a remote-code-
49
77
  // execution surface. Refuse any request that does not originate from the local
@@ -68,11 +96,35 @@ router.use((req, res, next) => {
68
96
  const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
69
97
  function isLoopbackRequest(req) {
70
98
  const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
71
- 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; }
72
117
  }
73
118
  router.use((req, res, next) => {
74
- if (ALLOW_REMOTE || isLoopbackRequest(req)) return next();
75
- 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();
76
128
  });
77
129
 
78
130
  // ── Console Log Capture ─────────────────────────────────────────────────────
@@ -175,6 +227,16 @@ function recordAuthFailure(req) {
175
227
  function recordAuthSuccess(req) {
176
228
  _authFails.delete(authClientKey(req));
177
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
+ }
178
240
 
179
241
  // Verify passkey endpoint (no auth middleware needed here)
180
242
  router.post('/api/auth', (req, res) => {
@@ -205,6 +267,7 @@ router.post('/api/auth', (req, res) => {
205
267
  return res.status(401).json({ error: 'Invalid passkey' });
206
268
  }
207
269
  recordAuthSuccess(req);
270
+ upgradeLegacyPasskey(passkey, stored);
208
271
  res.json({ ok: true });
209
272
  });
210
273
 
@@ -230,6 +293,7 @@ router.use('/api', (req, res, next) => {
230
293
  if (passkey) recordAuthFailure(req);
231
294
  return res.status(401).json({ error: 'Unauthorized' });
232
295
  }
296
+ upgradeLegacyPasskey(passkey, stored);
233
297
  next();
234
298
  });
235
299
 
@@ -502,7 +566,7 @@ router.get('/api/models', (req, res) => {
502
566
  // GET /api/models/:name
503
567
  router.get('/api/models/:name', (req, res) => {
504
568
  try {
505
- const name = req.params.name;
569
+ const name = safeStem(req.params.name);
506
570
  const { fields, indexes } = getModelSchemaDetailed(name);
507
571
  res.json({ name, fields, indexes });
508
572
  } catch (err) {
@@ -513,7 +577,7 @@ router.get('/api/models/:name', (req, res) => {
513
577
  // GET /api/models/:name/data — fetch stored documents from MongoDB collection
514
578
  router.get('/api/models/:name/data', async (req, res) => {
515
579
  try {
516
- const name = req.params.name;
580
+ const name = safeStem(req.params.name);
517
581
  const rawLimit = parseInt(req.query.limit);
518
582
  const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500);
519
583
  const skip = parseInt(req.query.skip) || 0;
@@ -548,7 +612,7 @@ router.get('/api/models/:name/data', async (req, res) => {
548
612
  // GET /api/models/:name/share — generate shareable endpoint documentation
549
613
  router.get('/api/models/:name/share', (req, res) => {
550
614
  try {
551
- const name = req.params.name;
615
+ const name = safeStem(req.params.name);
552
616
  const { fields } = getModelSchemaDetailed(name);
553
617
  const routes = listRoutes();
554
618
  const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
@@ -612,7 +676,7 @@ router.post('/api/models', (req, res) => {
612
676
  // PUT /api/models/:name
613
677
  router.put('/api/models/:name', (req, res) => {
614
678
  try {
615
- const name = req.params.name;
679
+ const name = safeStem(req.params.name);
616
680
  const { fields, indexes } = req.body;
617
681
  const pascalName = toPascalCase(name);
618
682
 
@@ -633,7 +697,7 @@ router.put('/api/models/:name', (req, res) => {
633
697
  // DELETE /api/models/:name
634
698
  router.delete('/api/models/:name', (req, res) => {
635
699
  try {
636
- const name = req.params.name;
700
+ const name = safeStem(req.params.name);
637
701
  const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
638
702
  if (!fs.existsSync(filePath)) {
639
703
  return res.status(404).json({ error: `Model "${name}" not found.` });
@@ -660,10 +724,10 @@ router.get('/api/routes', (req, res) => {
660
724
  // GET /api/routes/:name/content
661
725
  router.get('/api/routes/:name/content', (req, res) => {
662
726
  try {
663
- const name = req.params.name;
664
- 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');
665
729
  if (!fs.existsSync(filePath)) {
666
- return res.status(404).json({ error: `Route file "${name}" not found.` });
730
+ return res.status(404).json({ error: `Route file "${name}.js" not found.` });
667
731
  }
668
732
 
669
733
  const content = fs.readFileSync(filePath, 'utf8');
@@ -676,9 +740,9 @@ router.get('/api/routes/:name/content', (req, res) => {
676
740
  // DELETE /api/routes/:name
677
741
  router.delete('/api/routes/:name', (req, res) => {
678
742
  try {
679
- const name = req.params.name;
680
- const routeFile = name.endsWith('.js') ? name : name + '.js';
681
- const safeName = routeFile.replace('.js', '');
743
+ const name = safeStem(req.params.name);
744
+ const routeFile = name + '.js';
745
+ const safeName = name;
682
746
  const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
683
747
 
684
748
  if (!fs.existsSync(filePath)) {
@@ -710,9 +774,9 @@ router.delete('/api/routes/:name', (req, res) => {
710
774
  if (fs.existsSync(indexPath)) {
711
775
  let indexContent = fs.readFileSync(indexPath, 'utf8');
712
776
  // Remove import line
713
- 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'), '');
714
778
  // Remove app.use registration line
715
- 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'), '');
716
780
  fs.writeFileSync(indexPath, indexContent, 'utf8');
717
781
  }
718
782
  } catch (err) {
@@ -725,7 +789,7 @@ router.delete('/api/routes/:name', (req, res) => {
725
789
  // ---------------------------------------------------------------------------
726
790
  router.post('/api/routes/:name/add-method', async (req, res) => {
727
791
  try {
728
- const name = req.params.name.replace(/\.js$/, '');
792
+ const name = safeStem(req.params.name);
729
793
  const routeFile = name + '.js';
730
794
  const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
731
795
  if (!fs.existsSync(filePath)) {
@@ -1259,69 +1323,220 @@ router.post('/api/security/rate-limit', (req, res) => {
1259
1323
  // ---------------------------------------------------------------------------
1260
1324
  const mailerFilePath = () => path.join(process.cwd(), 'src', 'services', 'mailer.js');
1261
1325
 
1262
- // GET /api/mail — current config + provider presets for the Email page.
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.
1263
1398
  router.get('/api/mail', (req, res) => {
1264
1399
  try {
1265
1400
  const envPath = path.join(process.cwd(), '.env');
1401
+ const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
1266
1402
  res.json({
1267
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',
1268
1407
  providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
1269
- host: getEnvValue(envPath, 'SMTP_HOST'),
1270
- port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
1271
- secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
1272
- user: getEnvValue(envPath, 'SMTP_USER'),
1273
- from: getEnvValue(envPath, 'SMTP_FROM'),
1274
- hasPass: !!getEnvValue(envPath, 'SMTP_PASS'),
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
+ },
1275
1420
  });
1276
1421
  } catch (err) {
1277
1422
  res.status(500).json({ error: err.message });
1278
1423
  }
1279
1424
  });
1280
1425
 
1281
- // POST /api/mail/setup — write src/services/mailer.js, save SMTP_* to .env, install nodemailer.
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 }.
1282
1428
  router.post('/api/mail/setup', async (req, res) => {
1283
1429
  try {
1284
- const { host, port, secure, user, pass, from } = req.body || {};
1285
- if (!host || !String(host).trim()) return res.status(400).json({ error: 'SMTP host is required.' });
1286
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
+ }
1287
1447
 
1288
- // Persist SMTP config to .env first (a .env write does NOT restart nodemon).
1289
- // A blank password keeps the saved one.
1290
- updateEnvFile(envPath, 'SMTP_HOST', String(host).trim());
1291
- updateEnvFile(envPath, 'SMTP_PORT', String(port || 587).trim());
1292
- updateEnvFile(envPath, 'SMTP_SECURE', secure ? 'true' : 'false');
1293
- updateEnvFile(envPath, 'SMTP_USER', String(user || '').trim());
1294
- if (pass) updateEnvFile(envPath, 'SMTP_PASS', String(pass));
1295
- updateEnvFile(envPath, 'SMTP_FROM', String(from || user || '').trim());
1296
-
1297
- // Install nodemailer BEFORE writing mailer.js. Writing into src/ restarts
1298
- // nodemon, which would kill an in-flight npm install — so finish it first.
1299
- await installDeps('nodemailer');
1300
-
1301
- // Write the mailer service last (idempotent — keep an existing one). This is
1302
- // the step that triggers the nodemon restart, by which point install is done.
1303
- fs.mkdirSync(path.join(process.cwd(), 'src', 'services'), { recursive: true });
1304
- if (!fs.existsSync(mailerFilePath())) fs.writeFileSync(mailerFilePath(), buildMailerService(), 'utf8');
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
+ }
1305
1489
 
1306
- res.json({ message: 'Email configured. Use sendMail({ to, subject, html }) from src/services/mailer.js.' });
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');
1307
1502
  } catch (err) {
1308
1503
  if (!res.headersSent) res.status(500).json({ error: err.message });
1309
1504
  }
1310
1505
  });
1311
1506
 
1312
- // POST /api/mail/test — send a test email using the saved SMTP settings.
1507
+ // POST /api/mail/test — send a test email using the saved provider config.
1313
1508
  router.post('/api/mail/test', async (req, res) => {
1314
1509
  try {
1315
1510
  const to = String((req.body && req.body.to) || '').trim();
1316
1511
  if (!to) return res.status(400).json({ error: 'Recipient is required.' });
1317
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
+
1318
1535
  const host = getEnvValue(envPath, 'SMTP_HOST');
1319
1536
  if (!host) return res.status(400).json({ error: 'Email is not configured yet — save SMTP settings first.' });
1320
-
1321
1537
  let nodemailer;
1322
1538
  try { nodemailer = (await import('nodemailer')).default; }
1323
1539
  catch { return res.status(400).json({ error: 'nodemailer is still installing. Wait a moment after saving, then retry.' }); }
1324
-
1325
1540
  // Build the transport from .env directly (process.env may be stale post-setup).
1326
1541
  const transporter = nodemailer.createTransport({
1327
1542
  host,
@@ -1731,12 +1946,16 @@ router.post('/api/docs/regenerate', (req, res) => {
1731
1946
  router.post('/api/docs/password', (req, res) => {
1732
1947
  try {
1733
1948
  const password = String((req.body && req.body.password) || '');
1734
- if (password.length < 6) {
1735
- return res.status(400).json({ error: 'Password must be at least 6 characters.' });
1736
- }
1737
- 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');
1738
1957
  fs.mkdirSync(path.join(process.cwd(), 'src'), { recursive: true });
1739
- fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash }, null, 2) + '\n', 'utf8');
1958
+ fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash, secret }, null, 2) + '\n', 'utf8');
1740
1959
  const docsPath = path.join(process.cwd(), 'src', 'docs.js');
1741
1960
  const needsUpgrade = fs.existsSync(docsPath) && !fs.readFileSync(docsPath, 'utf8').includes('docs_auth');
1742
1961
  res.json({
@@ -2734,7 +2953,9 @@ ${name}.on('error', (err) => {
2734
2953
  // ---------------------------------------------------------------------------
2735
2954
  router.delete('/api/serialports/:name', (req, res) => {
2736
2955
  try {
2737
- 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, '');
2738
2959
  const parserName = name + 'Parser';
2739
2960
  let content = readIndexFile();
2740
2961
 
@@ -2843,6 +3064,8 @@ router.post('/api/api-tester/send', async (req, res) => {
2843
3064
  try {
2844
3065
  const { method, url, headers: reqHeaders, body: reqBody, bodyType = 'json', formFields = [] } = req.body;
2845
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 });
2846
3069
 
2847
3070
  const fetchOpts = { method: (method || 'GET').toUpperCase() };
2848
3071
  const headers = { ...(reqHeaders || {}) };
@@ -3008,7 +3231,9 @@ router.get('/api/logs', (req, res) => {
3008
3231
  });
3009
3232
 
3010
3233
  router.get('/api/logs/stream', (req, res) => {
3011
- // 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.
3012
3237
  const stored = getPasskeyHash();
3013
3238
  if (stored) {
3014
3239
  const lock = authLockState(req);
@@ -3016,7 +3241,7 @@ router.get('/api/logs/stream', (req, res) => {
3016
3241
  res.set('Retry-After', String(lock.retryAfter));
3017
3242
  return res.status(429).json({ error: 'Too many attempts. Try again later.' });
3018
3243
  }
3019
- const pk = req.query.passkey;
3244
+ const pk = req.headers['x-passkey'] || req.query.passkey;
3020
3245
  if (!verifyPasskey(pk, stored)) {
3021
3246
  if (pk) recordAuthFailure(req);
3022
3247
  return res.status(401).json({ error: 'Unauthorized' });
@@ -3062,6 +3287,9 @@ router.get('/shared/:id', (req, res) => {
3062
3287
  try {
3063
3288
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
3064
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');
3065
3293
  const filePath = path.join(sharedDir, req.params.id + '.json');
3066
3294
  if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
3067
3295
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -4,8 +4,22 @@
4
4
  # Port the server listens on
5
5
  PORT=3000
6
6
 
7
- # Rate limiting (only enforced when NODE_ENV=production)
8
- # RATE_LIMIT_WINDOW_MS = window length in ms (900000 = 15 minutes)
9
- # RATE_LIMIT_MAX = max requests per IP per window
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.
10
15
  RATE_LIMIT_WINDOW_MS=900000
11
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/
package/skeleton/index.js CHANGED
@@ -23,13 +23,15 @@ const __dirname = path.resolve();
23
23
  // ── Middleware ───────────────────────────────────────
24
24
  app.use(express.json({ limit: "50mb" }));
25
25
  app.use(express.urlencoded({ extended: true, limit: "50mb" }));
26
- app.use(cors({ origin: "*" }));
27
26
 
28
27
  // ── Dev Dashboard (local development only) ───────────
29
28
  // The dashboard at /_dev can write files and run shell commands — it is a
30
29
  // remote-code-execution surface and must NEVER be reachable in production.
31
30
  // In production it is not even imported. It additionally restricts itself to
32
- // loopback requests and requires a passkey (see .4bnode/dev-api.js).
31
+ // loopback requests, validates Host/Origin (defeats DNS-rebinding), and requires
32
+ // a passkey for remote access (see .4bnode/dev-api.js). It is mounted BEFORE the
33
+ // permissive global CORS below so `Access-Control-Allow-Origin: *` never applies
34
+ // to the dashboard.
33
35
  // • production (NODE_ENV=production): OFF — set _4BNODE_DASHBOARD=on to force.
34
36
  // • non-production: ON — set _4BNODE_DASHBOARD=off to disable.
35
37
  const dashboardEnabled =
@@ -42,16 +44,37 @@ if (dashboardEnabled) {
42
44
  } catch {}
43
45
  }
44
46
 
47
+ // ── CORS (applies to your API, not the /_dev dashboard above) ──
48
+ app.use(cors({ origin: "*" }));
49
+
45
50
  // ── Security (built-in by default) ───────────────────
46
51
  // Mounted AFTER the /_dev dashboard so its CDN/inline scripts aren't blocked by
47
52
  // helmet's CSP and the dashboard isn't throttled by the rate limiter.
48
- app.use(helmet());
53
+ // helmet() defaults to a strict Content-Security-Policy (script-src 'self',
54
+ // etc.) that blocks CDN-loaded scripts, inline <script> blocks, and cross-origin
55
+ // assets. We widen the policy so a typical frontend/docs page works while keeping
56
+ // the rest of helmet's protections. Tighten these directives for production.
57
+ app.use(helmet({
58
+ contentSecurityPolicy: {
59
+ directives: {
60
+ defaultSrc: ["'self'"],
61
+ scriptSrc: ["'self'", 'https:', "'unsafe-inline'"],
62
+ styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
63
+ imgSrc: ["'self'", 'data:', 'https:'],
64
+ connectSrc: ["'self'"],
65
+ },
66
+ },
67
+ crossOriginEmbedderPolicy: false,
68
+ // Allow other origins (e.g. a separate frontend host) to load assets served
69
+ // by this app. Default is 'same-origin'. Tighten to 'same-site' for production.
70
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
71
+ }));
49
72
 
50
73
  // ── Rate limiting ────────────────────────────────────
51
74
  // Tune via .env: RATE_LIMIT_WINDOW_MS (ms) and RATE_LIMIT_MAX (per window).
52
75
  const apiLimiter = rateLimit({
53
76
  windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
54
- max: Number(process.env.RATE_LIMIT_MAX) || 100,
77
+ max: Number(process.env.RATE_LIMIT_MAX) || 500,
55
78
  standardHeaders: true,
56
79
  legacyHeaders: false,
57
80
  });