neoagent 2.4.1-beta.45 → 2.4.1-beta.47

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.
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"c416acfeb8126e097f758c664aaa3da929e27d
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "2397226113" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "111346624" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -133824,7 +133824,7 @@ r===$&&A.b()
133824
133824
  o.push(A.j4(p,A.j8(!1,new A.a3(B.up,A.dl(new A.cv(B.hr,new A.a7p(r,p),p),p,p),p),!1,B.I,!0),p,p,0,0,0,p))}r=!1
133825
133825
  if(!s.ay)if(!s.ch){r=s.e
133826
133826
  r===$&&A.b()
133827
- r=B.b.t("mq6g3m8k-1096615").length!==0&&r.b}if(r){r=s.d
133827
+ r=B.b.t("mq73wuaf-558fe68").length!==0&&r.b}if(r){r=s.d
133828
133828
  r===$&&A.b()
133829
133829
  r=r.au&&!r.aQ?84:0
133830
133830
  q=s.e
@@ -139266,7 +139266,7 @@ $S:0}
139266
139266
  A.ZM.prototype={}
139267
139267
  A.Sw.prototype={
139268
139268
  n9(a){var s=this
139269
- if(B.b.t("mq6g3m8k-1096615").length===0||s.a!=null)return
139269
+ if(B.b.t("mq73wuaf-558fe68").length===0||s.a!=null)return
139270
139270
  s.AN()
139271
139271
  s.a=A.qh(B.Rb,new A.bas(s))},
139272
139272
  AN(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -139284,7 +139284,7 @@ if(!t.f.b(k)){s=1
139284
139284
  break}i=J.a_(k,"buildId")
139285
139285
  h=i==null?null:B.b.t(J.p(i))
139286
139286
  j=h==null?"":h
139287
- if(J.bf(j)===0||J.d(j,"mq6g3m8k-1096615")){s=1
139287
+ if(J.bf(j)===0||J.d(j,"mq73wuaf-558fe68")){s=1
139288
139288
  break}n.b=!0
139289
139289
  n.E()
139290
139290
  p=2
@@ -139301,7 +139301,7 @@ case 2:return A.i(o.at(-1),r)}})
139301
139301
  return A.k($async$AN,r)},
139302
139302
  vw(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
139303
139303
  var $async$vw=A.h(function(a2,a3){if(a2===1){o.push(a3)
139304
- s=p}for(;;)switch(s){case 0:if(B.b.t("mq6g3m8k-1096615").length===0||n.c){s=1
139304
+ s=p}for(;;)switch(s){case 0:if(B.b.t("mq73wuaf-558fe68").length===0||n.c){s=1
139305
139305
  break}n.c=!0
139306
139306
  n.E()
139307
139307
  p=4
@@ -22,6 +22,21 @@ const rateLimit = require('express-rate-limit');
22
22
  const router = express.Router();
23
23
  const ADMIN_DIR = path.join(__dirname, '..', 'admin');
24
24
 
25
+ const fs = require('fs');
26
+
27
+ // Admin sessions last 30 days and roll on every request.
28
+ const ADMIN_SESSION_TTL = 30 * 24 * 60 * 60 * 1000;
29
+
30
+ // Rolling refresh: touch the session on every authenticated admin request
31
+ // so the 30-day window slides forward from the last activity.
32
+ router.use((req, res, next) => {
33
+ if (req.session?.isAdmin) {
34
+ req.session.cookie.maxAge = ADMIN_SESSION_TTL;
35
+ req.session.touch();
36
+ }
37
+ next();
38
+ });
39
+
25
40
  const loginLimiter = rateLimit({
26
41
  windowMs: 15 * 60 * 1000,
27
42
  max: 20,
@@ -46,21 +61,55 @@ router.get('/login', (req, res) => {
46
61
  res.sendFile(path.join(ADMIN_DIR, 'login.html'));
47
62
  });
48
63
 
49
- router.post('/api/login', loginLimiter, express.json(), (req, res) => {
64
+ router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
50
65
  const { username, password } = req.body || {};
51
66
  const expectedUsername = process.env.ADMIN_USERNAME || 'admin';
52
67
  const expectedPassword = process.env.ADMIN_PASSWORD || '';
53
68
  if (!expectedPassword) {
54
69
  return res.status(503).json({ error: 'Admin credentials not configured. Run `neoagent setup`.' });
55
70
  }
56
- if (username === expectedUsername && password === expectedPassword) {
71
+ if (username !== expectedUsername || password !== expectedPassword) {
72
+ return res.status(401).json({ error: 'Invalid credentials' });
73
+ }
74
+ // Check whether 2FA is enabled
75
+ const adminTwoFactor = require('../services/account/admin_two_factor');
76
+ const tfStatus = adminTwoFactor.getStatus();
77
+ if (tfStatus.enabled) {
78
+ // Park the session in a "password OK, waiting for TOTP" state
79
+ req.session.adminPendingTwoFactor = true;
80
+ delete req.session.isAdmin;
81
+ return req.session.save((err) => {
82
+ if (err) return res.status(500).json({ error: 'Session error' });
83
+ res.json({ ok: false, requiresTwoFactor: true });
84
+ });
85
+ }
86
+ req.session.isAdmin = true;
87
+ req.session.cookie.maxAge = ADMIN_SESSION_TTL;
88
+ req.session.save((err) => {
89
+ if (err) return res.status(500).json({ error: 'Session error' });
90
+ res.json({ ok: true });
91
+ });
92
+ });
93
+
94
+ // Second-factor verification (called after a successful password login when 2FA is on)
95
+ router.post('/api/2fa/verify', loginLimiter, express.json(), async (req, res) => {
96
+ if (!req.session?.adminPendingTwoFactor) {
97
+ return res.status(400).json({ error: 'No pending 2FA verification' });
98
+ }
99
+ const { code } = req.body || {};
100
+ try {
101
+ const adminTwoFactor = require('../services/account/admin_two_factor');
102
+ const valid = await adminTwoFactor.verifyCode(code);
103
+ if (!valid) return res.status(401).json({ error: 'Invalid code — try again' });
104
+ req.session.adminPendingTwoFactor = false;
57
105
  req.session.isAdmin = true;
106
+ req.session.cookie.maxAge = ADMIN_SESSION_TTL;
58
107
  req.session.save((err) => {
59
108
  if (err) return res.status(500).json({ error: 'Session error' });
60
109
  res.json({ ok: true });
61
110
  });
62
- } else {
63
- res.status(401).json({ error: 'Invalid credentials' });
111
+ } catch (err) {
112
+ res.status(500).json({ error: err.message });
64
113
  }
65
114
  });
66
115
 
@@ -262,6 +311,268 @@ router.put('/api/update/channel', requireAdminAuth, (req, res) => {
262
311
  });
263
312
  });
264
313
 
314
+ const settingsLimiter = rateLimit({
315
+ windowMs: 15 * 60 * 1000,
316
+ max: 20,
317
+ standardHeaders: true,
318
+ legacyHeaders: false,
319
+ message: { error: 'Too many settings changes, slow down' },
320
+ });
321
+
322
+ const sqlLimiter = rateLimit({
323
+ windowMs: 15 * 60 * 1000,
324
+ max: 60,
325
+ standardHeaders: true,
326
+ legacyHeaders: false,
327
+ message: { error: 'Too many SQL queries, slow down' },
328
+ });
329
+
330
+ // --- Access settings (signup toggle + API key) ---
331
+
332
+ router.get('/api/settings', requireAdminAuth, (req, res) => {
333
+ const apiKey = process.env.ADMIN_API_KEY || '';
334
+ const hint = apiKey.length > 8
335
+ ? `${apiKey.slice(0, 4)}${'•'.repeat(4)}${apiKey.slice(-4)}`
336
+ : apiKey ? '•'.repeat(apiKey.length) : '';
337
+ const adminTwoFactor = require('../services/account/admin_two_factor');
338
+ const tfStatus = adminTwoFactor.getStatus();
339
+ res.json({
340
+ signupEnabled: process.env.NEOAGENT_ALLOW_SIGNUP !== 'false',
341
+ apiKeyConfigured: Boolean(apiKey),
342
+ apiKeyHint: hint,
343
+ twoFactor: tfStatus,
344
+ });
345
+ });
346
+
347
+ // --- 2FA management ---
348
+
349
+ router.get('/api/settings/2fa', requireAdminAuth, (req, res) => {
350
+ const adminTwoFactor = require('../services/account/admin_two_factor');
351
+ res.json(adminTwoFactor.getStatus());
352
+ });
353
+
354
+ router.post('/api/settings/2fa/setup', requireAdminAuth, settingsLimiter, async (req, res) => {
355
+ try {
356
+ const adminTwoFactor = require('../services/account/admin_two_factor');
357
+ const { otpauthUrl, manualKey } = adminTwoFactor.beginSetup();
358
+ const qrcode = require('qrcode');
359
+ const qrDataUrl = await qrcode.toDataURL(otpauthUrl, { width: 200, margin: 2 });
360
+ res.json({ qrDataUrl, manualKey });
361
+ } catch (err) {
362
+ res.status(err.statusCode || 500).json({ error: err.message });
363
+ }
364
+ });
365
+
366
+ router.post('/api/settings/2fa/enable', requireAdminAuth, settingsLimiter, express.json(), async (req, res) => {
367
+ try {
368
+ const adminTwoFactor = require('../services/account/admin_two_factor');
369
+ const { recoveryCodes } = await adminTwoFactor.enable(req.body?.code);
370
+ res.json({ ok: true, recoveryCodes });
371
+ } catch (err) {
372
+ res.status(err.statusCode || 500).json({ error: err.message });
373
+ }
374
+ });
375
+
376
+ router.delete('/api/settings/2fa', requireAdminAuth, settingsLimiter, express.json(), async (req, res) => {
377
+ try {
378
+ const adminTwoFactor = require('../services/account/admin_two_factor');
379
+ await adminTwoFactor.disable(req.body?.code);
380
+ res.json({ ok: true });
381
+ } catch (err) {
382
+ res.status(err.statusCode || 500).json({ error: err.message });
383
+ }
384
+ });
385
+
386
+ router.post('/api/settings/2fa/recovery-codes', requireAdminAuth, settingsLimiter, express.json(), async (req, res) => {
387
+ try {
388
+ const adminTwoFactor = require('../services/account/admin_two_factor');
389
+ const { recoveryCodes } = await adminTwoFactor.regenerateCodes(req.body?.code);
390
+ res.json({ ok: true, recoveryCodes });
391
+ } catch (err) {
392
+ res.status(err.statusCode || 500).json({ error: err.message });
393
+ }
394
+ });
395
+
396
+ router.put('/api/settings/signup', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
397
+ const enabled = req.body?.enabled !== false; // default true
398
+ const value = enabled ? 'true' : 'false';
399
+ upsertEnvValue(ENV_FILE, 'NEOAGENT_ALLOW_SIGNUP', value);
400
+ process.env.NEOAGENT_ALLOW_SIGNUP = value;
401
+ res.json({ ok: true, signupEnabled: enabled });
402
+ });
403
+
404
+ router.post('/api/settings/apikey/rotate', requireAdminAuth, settingsLimiter, (req, res) => {
405
+ const newKey = require('crypto').randomBytes(32).toString('hex');
406
+ upsertEnvValue(ENV_FILE, 'ADMIN_API_KEY', newKey);
407
+ process.env.ADMIN_API_KEY = newKey;
408
+ // Return the key once — it will not be shown again
409
+ res.json({ ok: true, apiKey: newKey });
410
+ });
411
+
412
+ router.delete('/api/settings/apikey', requireAdminAuth, settingsLimiter, (req, res) => {
413
+ upsertEnvValue(ENV_FILE, 'ADMIN_API_KEY', '');
414
+ delete process.env.ADMIN_API_KEY;
415
+ res.json({ ok: true });
416
+ });
417
+
418
+ // --- Analytics ---
419
+
420
+ router.get('/api/analytics', requireAdminAuth, (req, res) => {
421
+ const db = require('../db/database');
422
+ const now = new Date().toISOString();
423
+ const dayAgo = new Date(Date.now() - 86_400_000).toISOString();
424
+ const weekAgo = new Date(Date.now() - 7 * 86_400_000).toISOString();
425
+ try {
426
+ const stats = {
427
+ totalUsers: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
428
+ activeToday: db.prepare('SELECT COUNT(*) AS n FROM users WHERE last_login > ?').get(dayAgo).n,
429
+ newThisWeek: db.prepare('SELECT COUNT(*) AS n FROM users WHERE created_at > ?').get(weekAgo).n,
430
+ totalRuns: db.prepare('SELECT COUNT(*) AS n FROM agent_runs').get().n,
431
+ runsToday: db.prepare('SELECT COUNT(*) AS n FROM agent_runs WHERE created_at > ?').get(dayAgo).n,
432
+ totalTokens: db.prepare('SELECT COALESCE(SUM(total_tokens),0) AS n FROM agent_runs').get().n,
433
+ activeSessions:db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE revoked_at IS NULL AND expires_at > ?').get(now).n,
434
+ totalStorage: db.prepare('SELECT COALESCE(SUM(byte_size),0) AS n FROM artifacts').get().n,
435
+ };
436
+ const topUsers = db.prepare(`
437
+ SELECT u.id, u.username, u.display_name,
438
+ COALESCE(r.runs, 0) AS runs,
439
+ COALESCE(r.tokens, 0) AS tokens,
440
+ COALESCE(a.storage, 0) AS storage
441
+ FROM users u
442
+ LEFT JOIN (
443
+ SELECT user_id,
444
+ COUNT(*) AS runs,
445
+ COALESCE(SUM(total_tokens),0) AS tokens
446
+ FROM agent_runs GROUP BY user_id
447
+ ) r ON r.user_id = u.id
448
+ LEFT JOIN (
449
+ SELECT user_id, COALESCE(SUM(byte_size),0) AS storage
450
+ FROM artifacts GROUP BY user_id
451
+ ) a ON a.user_id = u.id
452
+ ORDER BY runs DESC LIMIT 8
453
+ `).all();
454
+ const recentRuns = db.prepare(`
455
+ SELECT r.id, u.username, r.title, r.status, r.total_tokens,
456
+ r.created_at, r.completed_at
457
+ FROM agent_runs r
458
+ JOIN users u ON u.id = r.user_id
459
+ ORDER BY r.created_at DESC LIMIT 20
460
+ `).all();
461
+ res.json({ stats, topUsers, recentRuns });
462
+ } catch (err) {
463
+ res.status(500).json({ error: String(err.message || err) });
464
+ }
465
+ });
466
+
467
+ // --- User management ---
468
+
469
+ router.get('/api/users', requireAdminAuth, (req, res) => {
470
+ const db = require('../db/database');
471
+ const q = req.query.q ? `%${req.query.q}%` : null;
472
+ try {
473
+ const sql = `
474
+ SELECT u.id, u.username, u.display_name, u.email, u.email_verified_at,
475
+ u.created_at, u.last_login,
476
+ COALESCE(r.run_count, 0) AS run_count,
477
+ COALESCE(a.storage_bytes,0) AS storage_bytes
478
+ FROM users u
479
+ LEFT JOIN (
480
+ SELECT user_id, COUNT(*) AS run_count
481
+ FROM agent_runs GROUP BY user_id
482
+ ) r ON r.user_id = u.id
483
+ LEFT JOIN (
484
+ SELECT user_id, COALESCE(SUM(byte_size),0) AS storage_bytes
485
+ FROM artifacts GROUP BY user_id
486
+ ) a ON a.user_id = u.id
487
+ ${q ? 'WHERE u.username LIKE ? OR u.email LIKE ?' : ''}
488
+ ORDER BY u.created_at DESC LIMIT 200`;
489
+ const users = q ? db.prepare(sql).all(q, q) : db.prepare(sql).all();
490
+ res.json({ users });
491
+ } catch (err) {
492
+ res.status(500).json({ error: String(err.message || err) });
493
+ }
494
+ });
495
+
496
+ router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
497
+ const db = require('../db/database');
498
+ const { DATA_DIR } = require('../../runtime/paths');
499
+ const { id } = req.params;
500
+ if (!id) return res.status(400).json({ error: 'Missing user id' });
501
+ try {
502
+ // Collect artifact paths before deletion
503
+ const artifacts = db.prepare('SELECT storage_path FROM artifacts WHERE user_id = ?').all(id);
504
+
505
+ const erase = db.transaction((uid) => {
506
+ db.prepare('DELETE FROM conversation_messages WHERE conversation_id IN (SELECT id FROM conversations WHERE user_id = ?)').run(uid);
507
+ db.prepare('DELETE FROM conversations WHERE user_id = ?').run(uid);
508
+ db.prepare('DELETE FROM agent_steps WHERE run_id IN (SELECT id FROM agent_runs WHERE user_id = ?)').run(uid);
509
+ db.prepare('DELETE FROM agent_runs WHERE user_id = ?').run(uid);
510
+ db.prepare('DELETE FROM messages WHERE user_id = ?').run(uid);
511
+ db.prepare('DELETE FROM memories WHERE user_id = ?').run(uid);
512
+ db.prepare('DELETE FROM integration_connections WHERE user_id = ?').run(uid);
513
+ db.prepare('DELETE FROM platform_connections WHERE user_id = ?').run(uid);
514
+ db.prepare('DELETE FROM mcp_servers WHERE user_id = ?').run(uid);
515
+ db.prepare('DELETE FROM user_settings WHERE user_id = ?').run(uid);
516
+ db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(uid);
517
+ db.prepare('DELETE FROM desktop_companion_devices WHERE user_id = ?').run(uid);
518
+ db.prepare('DELETE FROM scheduled_tasks WHERE user_id = ?').run(uid);
519
+ db.prepare('DELETE FROM recording_sessions WHERE user_id = ?').run(uid);
520
+ db.prepare('DELETE FROM screen_history WHERE user_id = ?').run(uid);
521
+ db.prepare('DELETE FROM agents WHERE user_id = ?').run(uid);
522
+ db.prepare('DELETE FROM artifacts WHERE user_id = ?').run(uid);
523
+ db.prepare('DELETE FROM users WHERE id = ?').run(uid);
524
+ });
525
+ erase(id);
526
+
527
+ // Clean up artifact files on disk
528
+ for (const artifact of artifacts) {
529
+ try {
530
+ const abs = path.isAbsolute(artifact.storage_path)
531
+ ? artifact.storage_path
532
+ : path.join(DATA_DIR, artifact.storage_path);
533
+ fs.rmSync(abs, { force: true });
534
+ } catch {}
535
+ }
536
+ try { fs.rmSync(path.join(DATA_DIR, 'artifacts', id), { recursive: true, force: true }); } catch {}
537
+
538
+ res.json({ ok: true });
539
+ } catch (err) {
540
+ res.status(500).json({ error: String(err.message || err) });
541
+ }
542
+ });
543
+
544
+ router.delete('/api/users/:id/sessions', requireAdminAuth, (req, res) => {
545
+ const db = require('../db/database');
546
+ const { id } = req.params;
547
+ try {
548
+ db.prepare('UPDATE user_sessions SET revoked_at = ? WHERE user_id = ?').run(new Date().toISOString(), id);
549
+ res.json({ ok: true });
550
+ } catch (err) {
551
+ res.status(500).json({ error: String(err.message || err) });
552
+ }
553
+ });
554
+
555
+ // --- SQL editor (read-only SELECT only) ---
556
+
557
+ const SQL_BLOCKED = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|DETACH|TRUNCATE|VACUUM|REINDEX|REPLACE|UPSERT|PRAGMA)\b/i;
558
+
559
+ router.post('/api/sql', requireAdminAuth, sqlLimiter, express.json(), (req, res) => {
560
+ const { query } = req.body || {};
561
+ if (!query || typeof query !== 'string') return res.status(400).json({ error: 'No query provided' });
562
+ const trimmed = query.trim();
563
+ if (!/^(SELECT|WITH)\b/i.test(trimmed)) return res.status(400).json({ error: 'Only SELECT (or WITH …) queries are allowed' });
564
+ if (SQL_BLOCKED.test(trimmed)) return res.status(400).json({ error: 'Query contains a blocked SQL keyword' });
565
+ try {
566
+ const db = require('../db/database');
567
+ const rows = db.prepare(trimmed).all();
568
+ const limited = rows.slice(0, 500);
569
+ const columns = limited.length ? Object.keys(limited[0]) : [];
570
+ res.json({ rows: limited, columns, truncated: rows.length > 500, total: rows.length });
571
+ } catch (err) {
572
+ res.status(400).json({ error: String(err.message || err) });
573
+ }
574
+ });
575
+
265
576
  // --- Providers ---
266
577
 
267
578
  const PROVIDERS = [
@@ -300,7 +611,7 @@ router.put('/api/providers', requireAdminAuth, express.json(), (req, res) => {
300
611
  if (!ALLOWED_PROVIDER_KEYS.has(key)) {
301
612
  return res.status(400).json({ error: 'Unknown provider key' });
302
613
  }
303
- const trimmed = String(value || '').trim();
614
+ const trimmed = String(value || '').trim().replace(/[\r\n]/g, '');
304
615
  upsertEnvValue(ENV_FILE, key, trimmed);
305
616
  if (trimmed) {
306
617
  process.env[key] = trimmed;
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const bcrypt = require('bcrypt');
4
+ const crypto = require('crypto');
5
+ const { generateSecret, generateURI, verifySync } = require('otplib');
6
+ const db = require('../../db/database');
7
+ const { encryptValue, decryptValue } = require('../integrations/secrets');
8
+
9
+ const TOTP_OPTS = { strategy: 'totp', digits: 6, period: 30, epochTolerance: 30 };
10
+ const RECOVERY_ALPHA = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
11
+
12
+ // ── Helpers ────────────────────────────────────────────────────────────────
13
+
14
+ function getRow() {
15
+ return db.prepare('SELECT * FROM admin_two_factor WHERE id = 1').get() || null;
16
+ }
17
+
18
+ function normalizeCode(v) {
19
+ return String(v || '').trim().replace(/\s+/g, '').toUpperCase();
20
+ }
21
+
22
+ function verifyTotpCode(secret, code) {
23
+ const token = String(code || '').trim().replace(/\s+/g, '');
24
+ if (!/^\d{6}$/.test(token)) return false;
25
+ return verifySync({ ...TOTP_OPTS, secret, token })?.valid === true;
26
+ }
27
+
28
+ function makeRecoveryCode() {
29
+ let raw = '';
30
+ while (raw.length < 10) raw += RECOVERY_ALPHA[crypto.randomInt(RECOVERY_ALPHA.length)];
31
+ return `${raw.slice(0, 5)}-${raw.slice(5)}`;
32
+ }
33
+
34
+ // ── Public API ─────────────────────────────────────────────────────────────
35
+
36
+ function getStatus() {
37
+ const row = getRow();
38
+ const codesLeft = db.prepare('SELECT COUNT(*) AS n FROM admin_recovery_codes WHERE used_at IS NULL').get().n;
39
+ return {
40
+ enabled: row?.enabled === 1,
41
+ pending: Boolean(row?.pending_secret),
42
+ enabledAt: row?.enabled_at || null,
43
+ recoveryCodesRemaining: codesLeft,
44
+ };
45
+ }
46
+
47
+ function beginSetup() {
48
+ if (!String(process.env.SESSION_SECRET || '').trim()) {
49
+ throw Object.assign(new Error('SESSION_SECRET must be configured before enabling 2FA'), { statusCode: 500 });
50
+ }
51
+ const secret = generateSecret();
52
+ const otpauthUrl = generateURI({ ...TOTP_OPTS, issuer: 'NeoAgent Admin', label: 'admin', secret });
53
+ db.prepare(`
54
+ INSERT INTO admin_two_factor (id, pending_secret, enabled, created_at, updated_at)
55
+ VALUES (1, ?, COALESCE((SELECT enabled FROM admin_two_factor WHERE id = 1), 0), datetime('now'), datetime('now'))
56
+ ON CONFLICT(id) DO UPDATE SET pending_secret = excluded.pending_secret, updated_at = datetime('now')
57
+ `).run(encryptValue(secret));
58
+ return { otpauthUrl, manualKey: secret };
59
+ }
60
+
61
+ async function enable(code) {
62
+ const row = getRow();
63
+ if (!row?.pending_secret) throw Object.assign(new Error('No 2FA setup pending'), { statusCode: 400 });
64
+ const secret = decryptValue(row.pending_secret);
65
+ if (!verifyTotpCode(secret, code)) throw Object.assign(new Error('Invalid code — try again'), { statusCode: 401 });
66
+
67
+ const codes = Array.from({ length: 10 }, makeRecoveryCode);
68
+ const hashes = await Promise.all(codes.map((c) => bcrypt.hash(normalizeCode(c).replace(/-/g, ''), 12)));
69
+
70
+ db.transaction(() => {
71
+ db.prepare('DELETE FROM admin_recovery_codes').run();
72
+ const ins = db.prepare("INSERT INTO admin_recovery_codes (code_hash, created_at) VALUES (?, datetime('now'))");
73
+ for (const h of hashes) ins.run(h);
74
+ db.prepare(`
75
+ UPDATE admin_two_factor
76
+ SET secret = pending_secret, pending_secret = NULL, enabled = 1,
77
+ enabled_at = datetime('now'), updated_at = datetime('now')
78
+ WHERE id = 1
79
+ `).run();
80
+ })();
81
+
82
+ return { recoveryCodes: codes };
83
+ }
84
+
85
+ /**
86
+ * Verifies a TOTP code or recovery code.
87
+ * Returns true if valid (or if 2FA is not enabled).
88
+ */
89
+ async function verifyCode(code) {
90
+ const row = getRow();
91
+ if (!row?.enabled || !row.secret) return true; // 2FA not configured
92
+
93
+ const secret = decryptValue(row.secret);
94
+ if (verifyTotpCode(secret, code)) return true;
95
+
96
+ // Try recovery code
97
+ const normalized = normalizeCode(String(code || '')).replace(/-/g, '');
98
+ if (normalized.length < 6) return false;
99
+ const rows = db.prepare('SELECT id, code_hash FROM admin_recovery_codes WHERE used_at IS NULL ORDER BY id').all();
100
+ for (const r of rows) {
101
+ if (await bcrypt.compare(normalized, r.code_hash)) {
102
+ db.prepare("UPDATE admin_recovery_codes SET used_at = datetime('now') WHERE id = ?").run(r.id);
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+
109
+ async function disable(code) {
110
+ const row = getRow();
111
+ if (!row?.enabled) return;
112
+ if (!await verifyCode(code)) throw Object.assign(new Error('Invalid 2FA code'), { statusCode: 401 });
113
+ db.transaction(() => {
114
+ db.prepare(`UPDATE admin_two_factor SET enabled = 0, secret = NULL, pending_secret = NULL,
115
+ enabled_at = NULL, updated_at = datetime('now') WHERE id = 1`).run();
116
+ db.prepare('DELETE FROM admin_recovery_codes').run();
117
+ })();
118
+ }
119
+
120
+ async function regenerateCodes(code) {
121
+ if (!await verifyCode(code)) throw Object.assign(new Error('Invalid 2FA code'), { statusCode: 401 });
122
+ const codes = Array.from({ length: 10 }, makeRecoveryCode);
123
+ const hashes = await Promise.all(codes.map((c) => bcrypt.hash(normalizeCode(c).replace(/-/g, ''), 12)));
124
+ db.transaction(() => {
125
+ db.prepare('DELETE FROM admin_recovery_codes').run();
126
+ const ins = db.prepare("INSERT INTO admin_recovery_codes (code_hash, created_at) VALUES (?, datetime('now'))");
127
+ for (const h of hashes) ins.run(h);
128
+ })();
129
+ return { recoveryCodes: codes };
130
+ }
131
+
132
+ module.exports = { getStatus, beginSetup, enable, verifyCode, disable, regenerateCodes };