4bnode 4.1.9 → 4.2.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.
@@ -38,8 +38,23 @@ import {
38
38
  extractFileFields,
39
39
  requiredFieldsFromZod,
40
40
  buildOpenApiSpec,
41
+ buildApiKeyMiddleware,
42
+ buildSerialDeviceModule,
43
+ buildSerialDevicesModule,
44
+ buildSerialRouter,
45
+ normalizeSerialDevice,
46
+ wireSerialIntoIndex,
47
+ hasLegacySerialPorts,
48
+ SERIAL_DEPS,
49
+ SERIAL_BAUD_RATES,
50
+ SERIAL_ID_PRESETS,
51
+ wireApiKeyGateIntoIndex,
52
+ generateApiKey,
53
+ hashApiKeyValue,
41
54
  buildDocsRouter,
42
55
  MAIL_PROVIDERS,
56
+ SANCHARAK_API_URL,
57
+ sancharakKeyProblem,
43
58
  buildMailerService,
44
59
  isOfficialMailDomain,
45
60
  OFFICIAL_MAIL_DOMAIN_PASSWORD,
@@ -166,6 +181,57 @@ process.on('unhandledRejection', (reason) => {
166
181
  _origError('Unhandled Rejection:', reason);
167
182
  });
168
183
 
184
+ // ── Request Metrics (feeds the live Dashboard overview) ─────────────────────
185
+ // index.js mounts `requestMetrics` app-wide (before /_dev) so it sees the
186
+ // project's own API traffic. Dashboard requests are skipped. Kept in memory
187
+ // only: a ring of recent requests and per-minute buckets for the last hour.
188
+ const _startedAt = Date.now();
189
+ const _recentRequests = [];
190
+ const _recentMax = 100;
191
+ const _minuteBuckets = new Map(); // minuteEpoch -> { count, errors }
192
+ const _durations = []; // last N durations (ms) for percentiles
193
+ const _durationsMax = 500;
194
+ const _metrics = { enabled: false, total: 0, errors: 0 };
195
+ const _lastMinute = []; // finish timestamps within the rolling last 60s
196
+
197
+ export function requestMetrics(req, res, next) {
198
+ _metrics.enabled = true;
199
+ if (req.originalUrl.startsWith('/_dev')) return next();
200
+ const start = process.hrtime.bigint();
201
+ res.on('finish', () => {
202
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
203
+ const isError = res.statusCode >= 500;
204
+ _metrics.total++;
205
+ if (isError) _metrics.errors++;
206
+ const minute = Math.floor(Date.now() / 60000);
207
+ const bucket = _minuteBuckets.get(minute) || { count: 0, errors: 0 };
208
+ bucket.count++;
209
+ if (res.statusCode >= 400) bucket.errors++;
210
+ _minuteBuckets.set(minute, bucket);
211
+ for (const k of _minuteBuckets.keys()) if (k < minute - 60) _minuteBuckets.delete(k);
212
+ const now = Date.now();
213
+ _lastMinute.push(now);
214
+ while (_lastMinute.length && _lastMinute[0] < now - 60000) _lastMinute.shift();
215
+ _durations.push(ms);
216
+ if (_durations.length > _durationsMax) _durations.shift();
217
+ _recentRequests.unshift({
218
+ method: req.method,
219
+ path: req.originalUrl.split('?')[0],
220
+ status: res.statusCode,
221
+ ms: Math.round(ms * 10) / 10,
222
+ ts: Date.now(),
223
+ });
224
+ if (_recentRequests.length > _recentMax) _recentRequests.length = _recentMax;
225
+ });
226
+ next();
227
+ }
228
+
229
+ function percentile(sorted, p) {
230
+ if (!sorted.length) return null;
231
+ const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
232
+ return Math.round(sorted[i] * 10) / 10;
233
+ }
234
+
169
235
  // Serve dashboard UI and assets
170
236
  const __4bnodeDir = path.join(process.cwd(), '.4bnode');
171
237
  router.get('/', (req, res) => {
@@ -1257,7 +1323,7 @@ router.get('/api/status', (req, res) => {
1257
1323
  const authMiddlewareExists = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
1258
1324
  const socketConfigured = indexContent.includes('setupSocketIO') || indexContent.includes('socket.io') || indexContent.includes('Server(server');
1259
1325
  const websocketConfigured = indexContent.includes('setupWebSocket') || indexContent.includes('WebSocketServer') || indexContent.includes("from 'ws'");
1260
- const serialportConfigured = indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
1326
+ const serialportConfigured = indexContent.includes('serialRouter)') || indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
1261
1327
 
1262
1328
  const mwExists = (f) => fs.existsSync(path.join(process.cwd(), 'src', 'middleware', f));
1263
1329
  const security = {
@@ -1284,13 +1350,181 @@ router.get('/api/status', (req, res) => {
1284
1350
  security,
1285
1351
  aiConfigured: !!getActiveAiKey(),
1286
1352
  docsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'docs.js')),
1287
- mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js'))
1353
+ mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js')),
1354
+ apiKeyGate: { wired: indexContent.includes('app.use(apiKeyGate)'), enabled: getEnvValue(path.join(process.cwd(), '.env'), 'API_KEY_REQUIRED') !== 'off', keys: readApiKeys().length },
1288
1355
  });
1289
1356
  } catch (err) {
1290
1357
  res.status(500).json({ error: err.message });
1291
1358
  }
1292
1359
  });
1293
1360
 
1361
+ // ---------------------------------------------------------------------------
1362
+ // Health — process stats + request metrics for the live Dashboard
1363
+ // ---------------------------------------------------------------------------
1364
+ router.get('/api/health', (req, res) => {
1365
+ const mem = process.memoryUsage();
1366
+ const nowMinute = Math.floor(Date.now() / 60000);
1367
+ const series = [];
1368
+ for (let m = nowMinute - 29; m <= nowMinute; m++) {
1369
+ const b = _minuteBuckets.get(m) || { count: 0, errors: 0 };
1370
+ series.push({ t: m * 60000, count: b.count, errors: b.errors });
1371
+ }
1372
+ const sorted = [..._durations].sort((a, b) => a - b);
1373
+ while (_lastMinute.length && _lastMinute[0] < Date.now() - 60000) _lastMinute.shift();
1374
+ const recentErrors = _logBuffer.filter(l => l.type === 'error').slice(-5).reverse();
1375
+ res.json({
1376
+ uptime: Math.round((Date.now() - _startedAt) / 1000),
1377
+ startedAt: _startedAt,
1378
+ node: process.version,
1379
+ pid: process.pid,
1380
+ platform: `${os.platform()} ${os.arch()}`,
1381
+ env: process.env.NODE_ENV || 'development',
1382
+ port: Number(process.env.PORT) || 3000,
1383
+ memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal },
1384
+ load: os.loadavg()[0],
1385
+ cpus: os.cpus().length,
1386
+ metrics: {
1387
+ enabled: _metrics.enabled,
1388
+ total: _metrics.total,
1389
+ errors: _metrics.errors,
1390
+ rpm: _lastMinute.length,
1391
+ p50: percentile(sorted, 50),
1392
+ p95: percentile(sorted, 95),
1393
+ series,
1394
+ recent: _recentRequests.slice(0, 15),
1395
+ },
1396
+ recentErrors,
1397
+ });
1398
+ });
1399
+
1400
+ // ---------------------------------------------------------------------------
1401
+ // API keys + app-wide API key gate
1402
+ // ---------------------------------------------------------------------------
1403
+ // Keys live in src/api-keys.json as SHA-256 hashes; the plaintext is returned
1404
+ // exactly once, at creation. src/middleware/apiKey.js re-reads the file on
1405
+ // change, so creating / revoking a key applies without a restart.
1406
+ const apiKeysFile = () => path.join(process.cwd(), 'src', 'api-keys.json');
1407
+
1408
+ function readApiKeys() {
1409
+ try {
1410
+ const list = JSON.parse(fs.readFileSync(apiKeysFile(), 'utf8'));
1411
+ return Array.isArray(list) ? list : [];
1412
+ } catch { return []; }
1413
+ }
1414
+
1415
+ function writeApiKeys(list) {
1416
+ fs.mkdirSync(path.dirname(apiKeysFile()), { recursive: true });
1417
+ fs.writeFileSync(apiKeysFile(), JSON.stringify(list, null, 2) + '\n', 'utf8');
1418
+ }
1419
+
1420
+ function apiKeyGateWired() {
1421
+ return readIndexFile().includes('app.use(apiKeyGate)');
1422
+ }
1423
+
1424
+ function apiKeyGateActive() {
1425
+ return apiKeyGateWired() && getEnvValue(path.join(process.cwd(), '.env'), 'API_KEY_REQUIRED') !== 'off';
1426
+ }
1427
+
1428
+ // Keep the hashed keys file out of git, like .env.
1429
+ function ensureGitignored(entry) {
1430
+ const gi = path.join(process.cwd(), '.gitignore');
1431
+ try {
1432
+ const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
1433
+ if (!cur.split(/\r?\n/).includes(entry)) fs.writeFileSync(gi, cur.trimEnd() + '\n' + entry + '\n', 'utf8');
1434
+ } catch {}
1435
+ }
1436
+
1437
+ function publicKeyView(k) {
1438
+ return { id: k.id, name: k.name, prefix: k.prefix || (k.key ? k.key.slice(0, 7) : ''), createdAt: k.createdAt || null, legacy: !!k.key };
1439
+ }
1440
+
1441
+ router.get('/api/security/api-keys', (req, res) => {
1442
+ try {
1443
+ const envPath = path.join(process.cwd(), '.env');
1444
+ res.json({
1445
+ gate: {
1446
+ wired: apiKeyGateWired(),
1447
+ enabled: getEnvValue(envPath, 'API_KEY_REQUIRED') !== 'off',
1448
+ publicPaths: getEnvValue(envPath, 'API_KEY_PUBLIC_PATHS') || '',
1449
+ },
1450
+ keys: readApiKeys().map(publicKeyView),
1451
+ });
1452
+ } catch (err) {
1453
+ res.status(500).json({ error: err.message });
1454
+ }
1455
+ });
1456
+
1457
+ // Create a key. The plaintext is in this response only; it is never stored.
1458
+ router.post('/api/security/api-keys', (req, res) => {
1459
+ try {
1460
+ const name = String((req.body && req.body.name) || '').replace(/[\r\n]/g, '').trim().slice(0, 60) || 'Unnamed key';
1461
+ const key = generateApiKey(crypto.randomBytes);
1462
+ const entry = {
1463
+ id: crypto.randomUUID(),
1464
+ name,
1465
+ prefix: key.slice(0, 7),
1466
+ hash: hashApiKeyValue(key, crypto.createHash),
1467
+ createdAt: new Date().toISOString(),
1468
+ };
1469
+ writeApiKeys([...readApiKeys(), entry]);
1470
+ ensureGitignored('src/api-keys.json');
1471
+ res.json({ key, ...publicKeyView(entry) });
1472
+ } catch (err) {
1473
+ res.status(500).json({ error: err.message });
1474
+ }
1475
+ });
1476
+
1477
+ router.delete('/api/security/api-keys/:id', (req, res) => {
1478
+ try {
1479
+ const list = readApiKeys();
1480
+ const next = list.filter((k) => k.id !== req.params.id);
1481
+ if (next.length === list.length) return res.status(404).json({ error: 'Key not found' });
1482
+ writeApiKeys(next);
1483
+ res.json({ message: 'Key revoked' });
1484
+ } catch (err) {
1485
+ res.status(500).json({ error: err.message });
1486
+ }
1487
+ });
1488
+
1489
+ // Turn the gate on/off and set public path prefixes. Both are read per request
1490
+ // from process.env, so they apply live. If the project predates the built-in
1491
+ // gate, this also writes the middleware and mounts it in index.js (restart).
1492
+ router.post('/api/security/api-gate', (req, res) => {
1493
+ try {
1494
+ const envPath = path.join(process.cwd(), '.env');
1495
+ const b = req.body || {};
1496
+ if (typeof b.enabled === 'boolean') {
1497
+ const v = b.enabled ? 'on' : 'off';
1498
+ updateEnvFile(envPath, 'API_KEY_REQUIRED', v);
1499
+ process.env.API_KEY_REQUIRED = v;
1500
+ }
1501
+ if (typeof b.publicPaths === 'string') {
1502
+ const clean = b.publicPaths.split(',').map((p) => p.trim()).filter((p) => /^\/[\w\-./]*$/.test(p)).join(',');
1503
+ updateEnvFile(envPath, 'API_KEY_PUBLIC_PATHS', clean);
1504
+ process.env.API_KEY_PUBLIC_PATHS = clean;
1505
+ }
1506
+ let wiredNow = false;
1507
+ if (b.enabled === true && !apiKeyGateWired()) {
1508
+ const mwPath = path.join(process.cwd(), 'src', 'middleware', 'apiKey.js');
1509
+ const existing = fs.existsSync(mwPath) ? fs.readFileSync(mwPath, 'utf8') : '';
1510
+ if (!existing.includes('export function apiKeyGate')) {
1511
+ fs.mkdirSync(path.dirname(mwPath), { recursive: true });
1512
+ fs.writeFileSync(mwPath, buildApiKeyMiddleware(), 'utf8');
1513
+ }
1514
+ ensureGitignored('src/api-keys.json');
1515
+ writeIndexFile(wireApiKeyGateIntoIndex(readIndexFile()));
1516
+ wiredNow = true;
1517
+ }
1518
+ // Keep the docs' code samples in step with the gate.
1519
+ if (fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'))) {
1520
+ try { generateSpecFile(); } catch {}
1521
+ }
1522
+ res.json({ message: wiredNow ? 'API key gate added to index.js. The app is restarting.' : 'Saved.', wiredNow });
1523
+ } catch (err) {
1524
+ res.status(500).json({ error: err.message });
1525
+ }
1526
+ });
1527
+
1294
1528
  // ---------------------------------------------------------------------------
1295
1529
  // Security configuration
1296
1530
  // ---------------------------------------------------------------------------
@@ -1334,12 +1568,14 @@ const safeServiceType = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9-]
1334
1568
  router.get('/api/discovery', async (req, res) => {
1335
1569
  try {
1336
1570
  const envPath = path.join(process.cwd(), '.env');
1337
- const enabled = getEnvValue(envPath, 'BONJOUR_ENABLED') !== 'off';
1571
+ const enabled = getEnvValue(envPath, 'BONJOUR_ENABLED') === 'on';
1338
1572
  const name = getEnvValue(envPath, 'BONJOUR_NAME');
1339
1573
  const type = getEnvValue(envPath, 'BONJOUR_TYPE') || 'http';
1574
+ const iface = getEnvValue(envPath, 'BONJOUR_INTERFACE');
1340
1575
  const mod = await loadDiscovery().catch(() => null);
1341
1576
  const self = mod && mod.getSelfState ? mod.getSelfState() : null;
1342
- res.json({ available: !!mod, enabled, config: { name, type }, self });
1577
+ const interfaces = mod && mod.listNetworkInterfaces ? mod.listNetworkInterfaces() : [];
1578
+ res.json({ available: !!mod, enabled, config: { name, type, interface: iface }, self, interfaces });
1343
1579
  } catch (err) {
1344
1580
  res.status(500).json({ error: err.message });
1345
1581
  }
@@ -1381,12 +1617,18 @@ router.post('/api/discovery/config', async (req, res) => {
1381
1617
  updateEnvFile(envPath, 'BONJOUR_TYPE', t);
1382
1618
  process.env.BONJOUR_TYPE = t;
1383
1619
  }
1620
+ // Interface NAME ("en0"); "" = all networks.
1621
+ if (typeof b.interface === 'string') {
1622
+ const iface = b.interface.replace(/[^a-zA-Z0-9_.:-]/g, '').slice(0, 40);
1623
+ updateEnvFile(envPath, 'BONJOUR_INTERFACE', iface);
1624
+ process.env.BONJOUR_INTERFACE = iface;
1625
+ }
1384
1626
 
1385
1627
  // Apply live in the running process.
1386
1628
  let self = null;
1387
1629
  const mod = await loadDiscovery().catch(() => null);
1388
1630
  if (mod) {
1389
- if (process.env.BONJOUR_ENABLED === 'off') {
1631
+ if (process.env.BONJOUR_ENABLED !== 'on') {
1390
1632
  if (mod.stopAdvertising) await mod.stopAdvertising();
1391
1633
  } else if (mod.startAdvertising) {
1392
1634
  const st = mod.getSelfState ? mod.getSelfState() : {};
@@ -1436,11 +1678,8 @@ async function notifyOfficialDomain(envPath, provider, fromAddr) {
1436
1678
  'Time: ' + new Date().toISOString(),
1437
1679
  ].join('\n');
1438
1680
 
1439
- if (provider === 'resend') {
1440
- const { Resend } = await import('resend');
1441
- const resend = new Resend(getEnvValue(envPath, 'RESEND_API_KEY'));
1442
- const { error } = await resend.emails.send({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
1443
- if (error) throw new Error(error.message || 'Resend notify failed');
1681
+ if (provider === 'sancharak') {
1682
+ await sancharakSendFromEnv(envPath, { from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
1444
1683
  return;
1445
1684
  }
1446
1685
  const nodemailer = (await import('nodemailer')).default;
@@ -1453,18 +1692,47 @@ async function notifyOfficialDomain(envPath, provider, fromAddr) {
1453
1692
  await transporter.sendMail({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
1454
1693
  }
1455
1694
 
1456
- // Verify a saved mail config actually works: an SMTP handshake, or a lightweight
1457
- // authenticated Resend call. Throws with a readable message on failure. A missing
1458
- // package (mid-install) is treated as "not yet verifiable", not a failure.
1695
+ // Send one email through the Sancharak API using the settings in .env (read
1696
+ // fresh: process.env can be stale right after a save). Mirrors sancharakSend in
1697
+ // the generated src/services/mailer.js.
1698
+ async function sancharakSendFromEnv(envPath, { from, to, subject, text, html, replyTo }) {
1699
+ const key = String(getEnvValue(envPath, 'SANCHARAK_API_KEY') || '').trim();
1700
+ const problem = sancharakKeyProblem(key);
1701
+ if (problem) throw new Error(problem);
1702
+ const body = { from: from || getEnvValue(envPath, 'MAIL_FROM'), to: [].concat(to), subject, stream: 'tx' };
1703
+ const reply = replyTo || getEnvValue(envPath, 'MAIL_REPLY_TO');
1704
+ if (reply) body.reply_to = [].concat(reply);
1705
+ if (text) body.text = text;
1706
+ if (html) body.html = html;
1707
+ const res = await fetch(getEnvValue(envPath, 'SANCHARAK_API_URL') || SANCHARAK_API_URL, {
1708
+ method: 'POST',
1709
+ headers: {
1710
+ Authorization: 'Bearer ' + key,
1711
+ 'Content-Type': 'application/json',
1712
+ 'Idempotency-Key': crypto.randomUUID(),
1713
+ },
1714
+ body: JSON.stringify(body),
1715
+ signal: AbortSignal.timeout(15000),
1716
+ });
1717
+ const raw = await res.text();
1718
+ let data = null;
1719
+ try { data = raw ? JSON.parse(raw) : null; } catch { data = { raw }; }
1720
+ if (!res.ok) {
1721
+ const msg = (data && (data.message || data.error || (data.error && data.error.message))) || raw || res.statusText;
1722
+ throw new Error('Sancharak ' + res.status + ': ' + (typeof msg === 'string' ? msg : JSON.stringify(msg)));
1723
+ }
1724
+ return data;
1725
+ }
1726
+
1727
+ // Verify a saved mail config: an SMTP handshake, or for Sancharak the key shape
1728
+ // and From address (sending is the only real check, so "Send test" confirms
1729
+ // delivery). Throws with a readable message on failure. A missing package
1730
+ // (mid-install) is treated as "not yet verifiable", not a failure.
1459
1731
  async function verifyMailConfig(envPath, provider) {
1460
- if (provider === 'resend') {
1461
- const key = getEnvValue(envPath, 'RESEND_API_KEY');
1462
- if (!key) throw new Error('Resend API key is missing.');
1463
- // Resend has no safe no-op verify — domains.list() needs a full-access key and
1464
- // rejects normal send-only keys, which would fail valid configs. So validate the
1465
- // key shape + From here, and let the "Send test" button confirm deliverability.
1466
- if (!/^re_/.test(key)) throw new Error('That does not look like a Resend API key — it should start with "re_".');
1467
- if (!getEnvValue(envPath, 'MAIL_FROM')) throw new Error('A From address is required for Postmaster.');
1732
+ if (provider === 'sancharak') {
1733
+ const problem = sancharakKeyProblem(getEnvValue(envPath, 'SANCHARAK_API_KEY'));
1734
+ if (problem) throw new Error(problem);
1735
+ if (!getEnvValue(envPath, 'MAIL_FROM')) throw new Error('A From address is required for Sancharak.');
1468
1736
  return;
1469
1737
  }
1470
1738
  const host = getEnvValue(envPath, 'SMTP_HOST');
@@ -1486,16 +1754,19 @@ async function verifyMailConfig(envPath, provider) {
1486
1754
  router.get('/api/mail', (req, res) => {
1487
1755
  try {
1488
1756
  const envPath = path.join(process.cwd(), '.env');
1489
- const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
1757
+ // Old projects saved MAIL_PROVIDER=resend (Postmaster); Sancharak replaces it.
1758
+ const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'sancharak').toLowerCase();
1490
1759
  res.json({
1491
1760
  configured: fs.existsSync(mailerFilePath()),
1492
1761
  // false only when the last save explicitly failed its connection test.
1493
- verified: getEnvValue(envPath, 'MAIL_VERIFIED') !== 'false',
1494
- provider: MAIL_PROVIDERS[provider] ? provider : 'resend',
1762
+ verified: getEnvValue(envPath, 'MAIL_VERIFIED') !== 'false' && provider !== 'resend',
1763
+ provider: MAIL_PROVIDERS[provider] ? provider : 'sancharak',
1764
+ legacyResend: provider === 'resend',
1495
1765
  providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
1496
- resend: {
1766
+ sancharak: {
1497
1767
  from: getEnvValue(envPath, 'MAIL_FROM'),
1498
- hasKey: !!getEnvValue(envPath, 'RESEND_API_KEY'),
1768
+ replyTo: getEnvValue(envPath, 'MAIL_REPLY_TO'),
1769
+ hasKey: !!getEnvValue(envPath, 'SANCHARAK_API_KEY'),
1499
1770
  },
1500
1771
  smtp: {
1501
1772
  host: getEnvValue(envPath, 'SMTP_HOST'),
@@ -1512,11 +1783,11 @@ router.get('/api/mail', (req, res) => {
1512
1783
  });
1513
1784
 
1514
1785
  // POST /api/mail/setup — save config to .env, write mailer.js, install the backend.
1515
- // Body: { provider: 'resend'|'smtp', ... }. resend → { apiKey, from }; smtp → { host, port, secure, user, pass, from }.
1786
+ // Body: { provider: 'sancharak'|'smtp', ... }. sancharak → { apiKey, from, replyTo }; smtp → { host, port, secure, user, pass, from }.
1516
1787
  router.post('/api/mail/setup', async (req, res) => {
1517
1788
  try {
1518
1789
  const envPath = path.join(process.cwd(), '.env');
1519
- const provider = String((req.body && req.body.provider) || 'resend').toLowerCase();
1790
+ const provider = String((req.body && req.body.provider) || 'sancharak').toLowerCase();
1520
1791
  if (!MAIL_PROVIDERS[provider]) return res.status(400).json({ error: 'Unknown mail provider.' });
1521
1792
 
1522
1793
  // Gate the official 4brains.in domain: it may not be used directly. Require the
@@ -1539,13 +1810,19 @@ router.post('/api/mail/setup', async (req, res) => {
1539
1810
  // guard allows sending from 4brains.in (direct/code use stays blocked).
1540
1811
  if (official) updateEnvFile(envPath, 'OFFICIAL_MAIL_AUTHORIZED', '1');
1541
1812
 
1542
- if (provider === 'resend') {
1543
- const { apiKey, from } = req.body || {};
1813
+ if (provider === 'sancharak') {
1814
+ const { apiKey, from, replyTo } = req.body || {};
1544
1815
  if (from !== undefined) updateEnvFile(envPath, 'MAIL_FROM', String(from || '').trim());
1545
- if (apiKey) updateEnvFile(envPath, 'RESEND_API_KEY', String(apiKey).trim());
1546
- if (!getEnvValue(envPath, 'RESEND_API_KEY')) return res.status(400).json({ error: 'A Resend API key is required.' });
1816
+ if (replyTo !== undefined) updateEnvFile(envPath, 'MAIL_REPLY_TO', String(replyTo || '').trim());
1817
+ // Reject a bad key BEFORE saving, so it can't overwrite a working one.
1818
+ if (apiKey) {
1819
+ const problem = sancharakKeyProblem(apiKey);
1820
+ if (problem) return res.status(400).json({ error: problem });
1821
+ updateEnvFile(envPath, 'SANCHARAK_API_KEY', String(apiKey).trim());
1822
+ }
1823
+ if (!getEnvValue(envPath, 'SANCHARAK_API_KEY')) return res.status(400).json({ error: 'A Sancharak API key is required.' });
1547
1824
  if (!getEnvValue(envPath, 'MAIL_FROM')) return res.status(400).json({ error: 'A From address is required.' });
1548
- await installDeps('resend');
1825
+ // No package to install: Sancharak is called with fetch.
1549
1826
  } else {
1550
1827
  const { host, port, secure, user, pass, from } = req.body || {};
1551
1828
  if (!host || !String(host).trim()) return res.status(400).json({ error: 'SMTP host is required.' });
@@ -1583,10 +1860,11 @@ router.post('/api/mail/setup', async (req, res) => {
1583
1860
  res.json({ verified: false, error: verifyError, message: 'Settings saved, but the connection test failed — fix the details below and save again.' + note });
1584
1861
  }
1585
1862
 
1586
- // Write/upgrade the mailer service last. Upgrade old single-backend mailers to
1587
- // the dual-backend version.
1863
+ // Write/upgrade the mailer service last. Upgrade old single-backend mailers and
1864
+ // the old Postmaster (Resend) mailer to the current Sancharak + SMTP version.
1588
1865
  fs.mkdirSync(path.join(process.cwd(), 'src', 'services'), { recursive: true });
1589
- const needsWrite = !fs.existsSync(mailerFilePath()) || !fs.readFileSync(mailerFilePath(), 'utf8').includes('MAIL_PROVIDER');
1866
+ const current = fs.existsSync(mailerFilePath()) ? fs.readFileSync(mailerFilePath(), 'utf8') : '';
1867
+ const needsWrite = !current || !current.includes('MAIL_PROVIDER') || current.includes("import('resend')");
1590
1868
  if (needsWrite) fs.writeFileSync(mailerFilePath(), buildMailerService(), 'utf8');
1591
1869
  } catch (err) {
1592
1870
  if (!res.headersSent) res.status(500).json({ error: err.message });
@@ -1599,26 +1877,25 @@ router.post('/api/mail/test', async (req, res) => {
1599
1877
  const to = String((req.body && req.body.to) || '').trim();
1600
1878
  if (!to) return res.status(400).json({ error: 'Recipient is required.' });
1601
1879
  const envPath = path.join(process.cwd(), '.env');
1602
- const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
1880
+ const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'sancharak').toLowerCase();
1603
1881
 
1604
1882
  if (provider === 'resend') {
1605
- const key = getEnvValue(envPath, 'RESEND_API_KEY');
1606
- if (!key) return res.status(400).json({ error: 'Set a Resend API key first.' });
1607
- let Resend;
1608
- try { ({ Resend } = await import('resend')); }
1609
- catch { return res.status(400).json({ error: 'resend is still installing. Wait a moment after saving, then retry.' }); }
1610
- const from = getEnvValue(envPath, 'MAIL_FROM');
1611
- if (!from) return res.status(400).json({ error: 'Set a From address (MAIL_FROM) before sending a test.' });
1612
- const resend = new Resend(key);
1613
- const { error } = await resend.emails.send({
1614
- from,
1615
- to,
1616
- subject: 'Test email from your app',
1617
- text: 'This is a test email confirming your Postmaster (Resend) settings work.',
1618
- html: '<p>This is a <b>test email</b> confirming your Postmaster (Resend) settings work.</p>',
1619
- });
1620
- if (error) return res.status(500).json({ error: 'Send failed: ' + (error.message || 'Resend error') });
1621
- return res.json({ message: `Test email sent to ${to}.` });
1883
+ return res.status(400).json({ error: 'Postmaster (Resend) has been replaced by Sancharak. Enter your Sancharak API key and save first.' });
1884
+ }
1885
+ if (provider === 'sancharak') {
1886
+ if (!getEnvValue(envPath, 'MAIL_FROM')) return res.status(400).json({ error: 'Set a From address (MAIL_FROM) before sending a test.' });
1887
+ try {
1888
+ const r = await sancharakSendFromEnv(envPath, {
1889
+ to,
1890
+ subject: 'Test email from your app',
1891
+ text: 'This is a test email confirming your Sancharak settings work.',
1892
+ html: '<p>This is a <b>test email</b> confirming your Sancharak settings work.</p>',
1893
+ });
1894
+ const id = r && (r.id || (r.data && r.data.id));
1895
+ return res.json({ message: `Test email sent to ${to}.` + (id ? ' Message id: ' + id : '') });
1896
+ } catch (e) {
1897
+ return res.status(502).json({ error: 'Send failed: ' + e.message });
1898
+ }
1622
1899
  }
1623
1900
 
1624
1901
  const host = getEnvValue(envPath, 'SMTP_HOST');
@@ -1974,7 +2251,7 @@ function collectEndpoints() {
1974
2251
  }
1975
2252
 
1976
2253
  function generateSpecFile() {
1977
- const spec = buildOpenApiSpec({ title: projectTitle(), endpoints: collectEndpoints(), models: collectModels() });
2254
+ const spec = buildOpenApiSpec({ title: projectTitle(), endpoints: collectEndpoints(), models: collectModels(), apiKey: apiKeyGateActive() });
1978
2255
  fs.writeFileSync(path.join(process.cwd(), 'src', 'openapi.json'), JSON.stringify(spec, null, 2), 'utf8');
1979
2256
  return spec;
1980
2257
  }
@@ -2976,69 +3253,265 @@ router.get('/api/serialports', (req, res) => {
2976
3253
  });
2977
3254
 
2978
3255
  // ---------------------------------------------------------------------------
2979
- // POST /api/add-serialport — add a named serial port connection
3256
+ // Serial devices (Arduino / ESP32). Devices live in src/serial/devices.json and
3257
+ // are matched at connect time (USB serial number / vendor id / firmware
3258
+ // identity), never by a hard-coded COM path. The device manager runs in THIS
3259
+ // process (started by index.js); we import the same module by absolute path so
3260
+ // the dashboard reads live state and can send commands.
2980
3261
  // ---------------------------------------------------------------------------
2981
- router.post('/api/add-serialport', async (req, res) => {
3262
+ const serialDir = () => path.join(process.cwd(), 'src', 'serial');
3263
+ const serialConfigFile = () => path.join(serialDir(), 'devices.json');
3264
+ const loadSerialModule = (file) => import(pathToFileURL(path.join(serialDir(), file)).href);
3265
+
3266
+ function readSerialConfigFile() {
2982
3267
  try {
2983
- const name = (req.body.name || 'serialPort').replace(/[^a-zA-Z0-9_]/g, '');
2984
- const portPath = req.body.portPath || '/dev/ttyUSB0';
2985
- const baud = req.body.baudRate || 9600;
2986
- const parserName = name + 'Parser';
2987
- const envPathKey = name.toUpperCase() + '_PATH';
2988
- const envBaudKey = name.toUpperCase() + '_BAUD';
3268
+ const list = JSON.parse(fs.readFileSync(serialConfigFile(), 'utf8'));
3269
+ return Array.isArray(list) ? list : [];
3270
+ } catch { return []; }
3271
+ }
2989
3272
 
2990
- // Install FIRST before modifying index.js
2991
- const indexContent = readIndexFile();
2992
- if (!indexContent.includes("from 'serialport'")) {
2993
- recordDeps(['serialport', '@serialport/parser-readline']);
2994
- await execAsync('npm install serialport @serialport/parser-readline', { cwd: process.cwd() });
2995
- }
3273
+ function serialWired() {
3274
+ return readIndexFile().includes('serialRouter)');
3275
+ }
3276
+
3277
+ // Write the generated files that are missing (never overwrite edits).
3278
+ function writeSerialFiles() {
3279
+ fs.mkdirSync(serialDir(), { recursive: true });
3280
+ const files = [
3281
+ [path.join(serialDir(), 'SerialDevice.js'), buildSerialDeviceModule()],
3282
+ [path.join(serialDir(), 'devices.js'), buildSerialDevicesModule()],
3283
+ [path.join(process.cwd(), 'src', 'routes', 'serial.js'), buildSerialRouter()],
3284
+ [serialConfigFile(), '[]\n'],
3285
+ ];
3286
+ const written = [];
3287
+ for (const [file, content] of files) {
3288
+ if (fs.existsSync(file)) continue;
3289
+ fs.mkdirSync(path.dirname(file), { recursive: true });
3290
+ fs.writeFileSync(file, content, 'utf8');
3291
+ written.push(path.relative(process.cwd(), file));
3292
+ }
3293
+ return written;
3294
+ }
3295
+
3296
+ async function serialPackageInstalled() {
3297
+ try { await import('serialport'); return true; } catch { return false; }
3298
+ }
3299
+
3300
+ // Apply devices.json to the running app without a restart.
3301
+ async function reloadLiveSerial() {
3302
+ if (!serialWired()) return null;
3303
+ try { return await (await loadSerialModule('devices.js')).reloadSerialDevices(); } catch { return null; }
3304
+ }
2996
3305
 
2997
- // Update .env
2998
- const envFile = path.join(process.cwd(), '.env');
2999
- updateEnvFile(envFile, envPathKey, portPath);
3000
- updateEnvFile(envFile, envBaudKey, String(baud));
3306
+ async function liveSerialDevice(name) {
3307
+ const mod = await loadSerialModule('devices.js');
3308
+ const dev = mod.getDevice(name);
3309
+ if (!dev) throw Object.assign(new Error('Device "' + name + '" is not running. Is it enabled?'), { status: 404 });
3310
+ return dev;
3311
+ }
3001
3312
 
3002
- // Send response BEFORE modifying index.js
3313
+ router.get('/api/serial', async (req, res) => {
3314
+ try {
3315
+ const wired = serialWired();
3316
+ let live = [];
3317
+ if (wired) {
3318
+ try { live = (await loadSerialModule('devices.js')).listSerialDevices(); } catch {}
3319
+ }
3320
+ const index = readIndexFile();
3003
3321
  res.json({
3004
- message: 'Serial port "' + name + '" added.',
3005
- files: ['index.js', '.env']
3322
+ installed: await serialPackageInstalled(),
3323
+ wired,
3324
+ config: readSerialConfigFile(),
3325
+ live,
3326
+ legacy: hasLegacySerialPorts(index),
3327
+ baudRates: SERIAL_BAUD_RATES,
3328
+ autoAdd: getEnvValue(path.join(process.cwd(), '.env'), 'SERIAL_AUTO_ADD') !== 'off',
3329
+ idPresets: SERIAL_ID_PRESETS,
3006
3330
  });
3331
+ } catch (err) {
3332
+ res.status(500).json({ error: err.message });
3333
+ }
3334
+ });
3335
+
3336
+ // One-time setup: install serialport, write the device manager + routes, wire
3337
+ // index.js (that last step restarts the app).
3338
+ router.post('/api/serial/setup', async (req, res) => {
3339
+ try {
3340
+ if (!(await serialPackageInstalled())) await installDeps(SERIAL_DEPS);
3341
+ if (!(await serialPackageInstalled())) {
3342
+ return res.status(500).json({ error: 'Could not install serialport. Run: npm install ' + SERIAL_DEPS.join(' ') });
3343
+ }
3344
+ const files = writeSerialFiles();
3345
+ const willRestart = !serialWired();
3346
+ res.json({ message: willRestart ? 'Serial support added. The app is restarting.' : 'Serial support is ready.', files });
3347
+ if (willRestart) writeIndexFile(wireSerialIntoIndex(readIndexFile()));
3348
+ } catch (err) {
3349
+ if (!res.headersSent) res.status(500).json({ error: err.message });
3350
+ }
3351
+ });
3352
+
3353
+ // Hotplug events (board found / attached / removed / auto-added) after ?since=<id>.
3354
+ // Polled by the dashboard to toast "board connected" on any page.
3355
+ router.get('/api/serial/events', async (req, res) => {
3356
+ try {
3357
+ if (!serialWired()) return res.json([]);
3358
+ const mod = await loadSerialModule('devices.js');
3359
+ res.json(mod.getSerialEvents ? mod.getSerialEvents(Number(req.query.since) || 0) : []);
3360
+ } catch {
3361
+ res.json([]);
3362
+ }
3363
+ });
3364
+
3365
+ // Auto-add new boards on/off (SERIAL_AUTO_ADD). Read per event, so it applies live.
3366
+ router.post('/api/serial/settings', (req, res) => {
3367
+ try {
3368
+ if (typeof (req.body && req.body.autoAdd) !== 'boolean') return res.status(400).json({ error: 'autoAdd must be true or false' });
3369
+ const v = req.body.autoAdd ? 'on' : 'off';
3370
+ updateEnvFile(path.join(process.cwd(), '.env'), 'SERIAL_AUTO_ADD', v);
3371
+ process.env.SERIAL_AUTO_ADD = v;
3372
+ res.json({ message: req.body.autoAdd ? 'New boards are added automatically.' : 'New boards are only listed, not added.' });
3373
+ } catch (err) {
3374
+ res.status(500).json({ error: err.message });
3375
+ }
3376
+ });
3377
+
3378
+ // Every serial port with its USB metadata (vendor id, serial number, ...).
3379
+ router.get('/api/serial/scan', async (req, res) => {
3380
+ try {
3381
+ if (!fs.existsSync(path.join(serialDir(), 'SerialDevice.js'))) writeSerialFiles();
3382
+ const mod = await loadSerialModule('SerialDevice.js');
3383
+ res.json(await mod.listPorts());
3384
+ } catch (err) {
3385
+ res.status(500).json({ error: err.message });
3386
+ }
3387
+ });
3007
3388
 
3008
- // Modify index.js LAST
3009
- addImportToIndex("import { SerialPort } from 'serialport';");
3010
- addImportToIndex("import { ReadlineParser } from '@serialport/parser-readline';");
3389
+ // Point 7 of the serial guide: every USB port with USB metadata AND firmware
3390
+ // identity, probing free ports one at a time (opening resets most boards).
3391
+ router.post('/api/serial/identify-all', async (req, res) => {
3392
+ try {
3393
+ const b = req.body || {};
3394
+ const opts = {
3395
+ baudRate: Number(b.baudRate) || 115200,
3396
+ command: String(b.command || 'ID?').slice(0, 40),
3397
+ refresh: b.refresh === true,
3398
+ };
3399
+ // Use the running device manager when wired, so its open ports are skipped.
3400
+ const mod = serialWired() ? await loadSerialModule('devices.js') : await loadSerialModule('SerialDevice.js');
3401
+ res.json(await mod.listDevices(opts));
3402
+ } catch (err) {
3403
+ res.status(500).json({ error: err.message });
3404
+ }
3405
+ });
3011
3406
 
3012
- const serialCode = `const ${name} = new SerialPort({
3013
- path: process.env.${envPathKey} || '${portPath}',
3014
- baudRate: parseInt(process.env.${envBaudKey}) || ${baud}
3407
+ // Ask a board who it is: open, wait out the reset, send the id command.
3408
+ router.post('/api/serial/probe', async (req, res) => {
3409
+ try {
3410
+ const b = req.body || {};
3411
+ const portPath = String(b.path || '');
3412
+ if (!portPath) return res.status(400).json({ error: 'path is required' });
3413
+ const mod = await loadSerialModule('SerialDevice.js');
3414
+ const known = (await mod.listPorts()).find((p) => p.path === portPath);
3415
+ if (!known) return res.status(404).json({ error: 'No such port' });
3416
+ if (known.inUse) return res.status(409).json({ error: 'A configured device is using this port.' });
3417
+ const opts = {
3418
+ baudRate: Number(b.baudRate) || 115200,
3419
+ command: String(b.command || 'ID?').slice(0, 40),
3420
+ delimiter: b.delimiter === 'crlf' ? 'crlf' : 'lf',
3421
+ };
3422
+ // tryAll: ID?, M115, $I, ATI in turn; reports which command answered.
3423
+ if (b.tryAll && mod.identifyBoard) {
3424
+ const found = await mod.identifyBoard(portPath, opts);
3425
+ return res.json({ identity: found ? found.identity : null, command: found ? found.command : null });
3426
+ }
3427
+ const identity = await mod.probeIdentity(portPath, opts);
3428
+ res.json({ identity, command: identity ? opts.command : null });
3429
+ } catch (err) {
3430
+ res.status(500).json({ error: err.message });
3431
+ }
3015
3432
  });
3016
3433
 
3017
- const ${parserName} = ${name}.pipe(new ReadlineParser({ delimiter: '\\r\\n' }));
3434
+ // Create or update a device (body.originalName renames).
3435
+ router.post('/api/serial/devices', async (req, res) => {
3436
+ try {
3437
+ const device = normalizeSerialDevice(req.body || {});
3438
+ const original = String((req.body && req.body.originalName) || device.name);
3439
+ const list = readSerialConfigFile().filter((d) => d.name !== original);
3440
+ if (list.some((d) => d.name === device.name)) return res.status(409).json({ error: 'A device named "' + device.name + '" already exists.' });
3441
+ list.push(device);
3442
+ fs.mkdirSync(serialDir(), { recursive: true });
3443
+ fs.writeFileSync(serialConfigFile(), JSON.stringify(list, null, 2) + '\n', 'utf8');
3444
+ const live = await reloadLiveSerial();
3445
+ res.json({ message: 'Device "' + device.name + '" saved.', device, live });
3446
+ } catch (err) {
3447
+ res.status(400).json({ error: err.message });
3448
+ }
3449
+ });
3018
3450
 
3019
- ${name}.on('open', () => {
3020
- console.log('${name}: port opened');
3451
+ router.delete('/api/serial/devices/:name', async (req, res) => {
3452
+ try {
3453
+ const list = readSerialConfigFile();
3454
+ const next = list.filter((d) => d.name !== req.params.name);
3455
+ if (next.length === list.length) return res.status(404).json({ error: 'Device not found' });
3456
+ fs.writeFileSync(serialConfigFile(), JSON.stringify(next, null, 2) + '\n', 'utf8');
3457
+ await reloadLiveSerial();
3458
+ res.json({ message: 'Device "' + req.params.name + '" removed.' });
3459
+ } catch (err) {
3460
+ res.status(500).json({ error: err.message });
3461
+ }
3021
3462
  });
3022
3463
 
3023
- ${parserName}.on('data', (data) => {
3024
- console.log('${name} data:', data);
3464
+ // Monitor backfill: the last (up to 1000) lines a device sent and received.
3465
+ router.get('/api/serial/devices/:name/recent', async (req, res) => {
3466
+ try {
3467
+ const dev = await liveSerialDevice(req.params.name);
3468
+ res.json(dev.recent.slice(-1000));
3469
+ } catch (err) {
3470
+ res.status(err.status || 500).json({ error: err.message });
3471
+ }
3025
3472
  });
3026
3473
 
3027
- ${name}.on('error', (err) => {
3028
- console.error('${name} error:', err.message);
3029
- });`;
3474
+ // Live serial monitor for the dashboard: Server-Sent Events of every line and
3475
+ // status change. The dashboard reads it with fetch() so the passkey travels in
3476
+ // the x-passkey header (checked by the /api middleware), never in the URL.
3477
+ router.get('/api/serial/stream', async (req, res) => {
3478
+ let mod;
3479
+ try {
3480
+ if (!serialWired()) return res.status(409).json({ error: 'Serial is not set up' });
3481
+ mod = await loadSerialModule('devices.js');
3482
+ if (!mod.serialBus) return res.status(409).json({ error: 'Update src/serial/devices.js to use the live monitor' });
3483
+ } catch (err) {
3484
+ return res.status(500).json({ error: err.message });
3485
+ }
3486
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
3487
+ res.write(':\n\n');
3488
+ const onEvent = (e) => res.write('data: ' + JSON.stringify(e) + '\n\n');
3489
+ mod.serialBus.on('event', onEvent);
3490
+ const ping = setInterval(() => res.write(':\n\n'), 25000);
3491
+ req.on('close', () => { clearInterval(ping); mod.serialBus.off('event', onEvent); });
3492
+ });
3030
3493
 
3031
- let current = readIndexFile();
3032
- if (!current.includes(`const ${name} = new SerialPort`)) {
3033
- insertBeforeListen(serialCode);
3494
+ // Send a command and wait for the reply (or just write when expectReply=false).
3495
+ router.post('/api/serial/devices/:name/send', async (req, res) => {
3496
+ try {
3497
+ const dev = await liveSerialDevice(req.params.name);
3498
+ const command = String((req.body && req.body.command) || '');
3499
+ if (!command.trim()) return res.status(400).json({ error: 'command is required' });
3500
+ if (req.body.expectReply === false) {
3501
+ await dev.write(command);
3502
+ return res.json({ ok: true });
3034
3503
  }
3504
+ const started = Date.now();
3505
+ const reply = await dev.send(command, { timeout: req.body.timeout });
3506
+ res.json({ reply, ms: Date.now() - started });
3035
3507
  } catch (err) {
3036
- if (!res.headersSent) res.status(500).json({ error: err.message });
3508
+ res.status(err.status || 504).json({ error: err.message });
3037
3509
  }
3038
3510
  });
3039
3511
 
3040
3512
  // ---------------------------------------------------------------------------
3041
- // DELETE /api/serialports/:name — remove a serial port from index.js
3513
+ // DELETE /api/serialports/:name — remove a LEGACY serial port (inlined in index.js
3514
+ // by older 4bnode versions, with a hard-coded path) from index.js
3042
3515
  // ---------------------------------------------------------------------------
3043
3516
  router.delete('/api/serialports/:name', (req, res) => {
3044
3517
  try {