neoagent 3.0.1-beta.2 → 3.0.1-beta.21

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 (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. package/server/services/workspace/manager.js +37 -3
@@ -64,8 +64,11 @@ router.get('/login', (req, res) => {
64
64
 
65
65
  router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
66
66
  const { username, password } = req.body || {};
67
- const expectedUsername = process.env.ADMIN_USERNAME || 'admin';
68
- const expectedPassword = process.env.ADMIN_PASSWORD || 'admin';
67
+ const expectedUsername = process.env.ADMIN_USERNAME;
68
+ const expectedPassword = process.env.ADMIN_PASSWORD;
69
+ if (!expectedUsername || !expectedPassword) {
70
+ return res.status(503).json({ error: 'Admin interface is not configured. Set ADMIN_USERNAME and ADMIN_PASSWORD environment variables.' });
71
+ }
69
72
  if (username !== expectedUsername || password !== expectedPassword) {
70
73
  return res.status(401).json({ error: 'Invalid credentials' });
71
74
  }
@@ -81,11 +84,14 @@ router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
81
84
  res.json({ ok: false, requiresTwoFactor: true });
82
85
  });
83
86
  }
84
- req.session.isAdmin = true;
85
- req.session.cookie.maxAge = ADMIN_SESSION_TTL;
86
- req.session.save((err) => {
87
+ req.session.regenerate((err) => {
87
88
  if (err) return res.status(500).json({ error: 'Session error' });
88
- res.json({ ok: true });
89
+ req.session.isAdmin = true;
90
+ req.session.cookie.maxAge = ADMIN_SESSION_TTL;
91
+ req.session.save((saveErr) => {
92
+ if (saveErr) return res.status(500).json({ error: 'Session error' });
93
+ res.json({ ok: true });
94
+ });
89
95
  });
90
96
  });
91
97
 
@@ -99,12 +105,14 @@ router.post('/api/2fa/verify', loginLimiter, express.json(), async (req, res) =>
99
105
  const adminTwoFactor = require('../services/account/admin_two_factor');
100
106
  const valid = await adminTwoFactor.verifyCode(code);
101
107
  if (!valid) return res.status(401).json({ error: 'Invalid code — try again' });
102
- req.session.adminPendingTwoFactor = false;
103
- req.session.isAdmin = true;
104
- req.session.cookie.maxAge = ADMIN_SESSION_TTL;
105
- req.session.save((err) => {
108
+ req.session.regenerate((err) => {
106
109
  if (err) return res.status(500).json({ error: 'Session error' });
107
- res.json({ ok: true });
110
+ req.session.isAdmin = true;
111
+ req.session.cookie.maxAge = ADMIN_SESSION_TTL;
112
+ req.session.save((saveErr) => {
113
+ if (saveErr) return res.status(500).json({ error: 'Session error' });
114
+ res.json({ ok: true });
115
+ });
108
116
  });
109
117
  } catch (err) {
110
118
  res.status(500).json({ error: err.message });
@@ -555,49 +563,16 @@ router.get('/api/users', requireAdminAuth, (req, res) => {
555
563
  });
556
564
 
557
565
  router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
558
- const db = require('../db/database');
559
- const { DATA_DIR } = require('../../runtime/paths');
566
+ const { eraseUserData } = require('../services/account/erasure');
560
567
  const { id } = req.params;
561
568
  if (!id) return res.status(400).json({ error: 'Missing user id' });
569
+ if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'Invalid user id' });
562
570
  try {
563
- // Collect artifact paths before deletion
564
- const artifacts = db.prepare('SELECT storage_path FROM artifacts WHERE user_id = ?').all(id);
565
-
566
- const erase = db.transaction((uid) => {
567
- db.prepare('DELETE FROM conversation_messages WHERE conversation_id IN (SELECT id FROM conversations WHERE user_id = ?)').run(uid);
568
- db.prepare('DELETE FROM conversations WHERE user_id = ?').run(uid);
569
- db.prepare('DELETE FROM agent_steps WHERE run_id IN (SELECT id FROM agent_runs WHERE user_id = ?)').run(uid);
570
- db.prepare('DELETE FROM agent_runs WHERE user_id = ?').run(uid);
571
- db.prepare('DELETE FROM messages WHERE user_id = ?').run(uid);
572
- db.prepare('DELETE FROM memories WHERE user_id = ?').run(uid);
573
- db.prepare('DELETE FROM integration_connections WHERE user_id = ?').run(uid);
574
- db.prepare('DELETE FROM platform_connections WHERE user_id = ?').run(uid);
575
- db.prepare('DELETE FROM mcp_servers WHERE user_id = ?').run(uid);
576
- db.prepare('DELETE FROM user_settings WHERE user_id = ?').run(uid);
577
- db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(uid);
578
- db.prepare('DELETE FROM desktop_companion_devices WHERE user_id = ?').run(uid);
579
- db.prepare('DELETE FROM scheduled_tasks WHERE user_id = ?').run(uid);
580
- db.prepare('DELETE FROM recording_sessions WHERE user_id = ?').run(uid);
581
- db.prepare('DELETE FROM screen_history WHERE user_id = ?').run(uid);
582
- db.prepare('DELETE FROM agents WHERE user_id = ?').run(uid);
583
- db.prepare('DELETE FROM artifacts WHERE user_id = ?').run(uid);
584
- db.prepare('DELETE FROM users WHERE id = ?').run(uid);
585
- });
586
- erase(id);
587
-
588
- // Clean up artifact files on disk
589
- for (const artifact of artifacts) {
590
- try {
591
- const abs = path.isAbsolute(artifact.storage_path)
592
- ? artifact.storage_path
593
- : path.join(DATA_DIR, artifact.storage_path);
594
- fs.rmSync(abs, { force: true });
595
- } catch {}
596
- }
597
- try { fs.rmSync(path.join(DATA_DIR, 'artifacts', id), { recursive: true, force: true }); } catch {}
598
-
599
- res.json({ ok: true });
571
+ const result = eraseUserData(id, { runtimeManager: req.app.locals.runtimeManager });
572
+ res.json(result);
600
573
  } catch (err) {
574
+ if (err.code === 'NOT_FOUND') return res.status(404).json({ error: 'User not found' });
575
+ if (err.code === 'INVALID_ID') return res.status(400).json({ error: 'Invalid user id' });
601
576
  res.status(500).json({ error: String(err.message || err) });
602
577
  }
603
578
  });
@@ -625,10 +600,15 @@ router.post('/api/sql', requireAdminAuth, sqlLimiter, express.json(), (req, res)
625
600
  if (SQL_BLOCKED.test(trimmed)) return res.status(400).json({ error: 'Query contains a blocked SQL keyword' });
626
601
  try {
627
602
  const db = require('../db/database');
628
- const rows = db.prepare(trimmed).all();
629
- const limited = rows.slice(0, 500);
603
+ const limited = [];
604
+ for (const row of db.prepare(trimmed).iterate()) {
605
+ limited.push(row);
606
+ if (limited.length > 500) break;
607
+ }
608
+ const truncated = limited.length > 500;
609
+ if (truncated) limited.pop();
630
610
  const columns = limited.length ? Object.keys(limited[0]) : [];
631
- res.json({ rows: limited, columns, truncated: rows.length > 500, total: rows.length });
611
+ res.json({ rows: limited, columns, truncated, total: truncated ? null : limited.length });
632
612
  } catch (err) {
633
613
  res.status(400).json({ error: String(err.message || err) });
634
614
  }
@@ -637,16 +617,22 @@ router.post('/api/sql', requireAdminAuth, sqlLimiter, express.json(), (req, res)
637
617
  // --- Providers ---
638
618
 
639
619
  const PROVIDERS = [
640
- { key: 'ANTHROPIC_API_KEY', label: 'Anthropic (Claude)', type: 'key' },
641
- { key: 'OPENAI_API_KEY', label: 'OpenAI', type: 'key' },
642
- { key: 'XAI_API_KEY', label: 'xAI (Grok)', type: 'key' },
643
- { key: 'GOOGLE_AI_KEY', label: 'Google (Gemini)', type: 'key' },
644
- { key: 'MINIMAX_API_KEY', label: 'MiniMax', type: 'key' },
645
- { key: 'NVIDIA_API_KEY', label: 'NVIDIA NIM', type: 'key' },
646
- { key: 'OPENROUTER_API_KEY', label: 'OpenRouter', type: 'key' },
647
- { key: 'BRAVE_SEARCH_API_KEY', label: 'Brave Search', type: 'key' },
648
- { key: 'DEEPGRAM_API_KEY', label: 'Deepgram (Voice)', type: 'key' },
649
- { key: 'OLLAMA_URL', label: 'Ollama (Local)', type: 'url' },
620
+ { key: 'ANTHROPIC_API_KEY', label: 'Anthropic (Claude)', type: 'key' },
621
+ { key: 'OPENAI_API_KEY', label: 'OpenAI', type: 'key' },
622
+ { key: 'XAI_API_KEY', label: 'xAI (Grok)', type: 'key' },
623
+ { key: 'GOOGLE_AI_KEY', label: 'Google (Gemini)', type: 'key' },
624
+ { key: 'MINIMAX_API_KEY', label: 'MiniMax', type: 'key' },
625
+ { key: 'NVIDIA_API_KEY', label: 'NVIDIA NIM', type: 'key' },
626
+ { key: 'OPENROUTER_API_KEY', label: 'OpenRouter', type: 'key' },
627
+ { key: 'BRAVE_SEARCH_API_KEY', label: 'Brave Search', type: 'key' },
628
+ { key: 'DEEPGRAM_API_KEY', label: 'Deepgram (Voice)', type: 'key' },
629
+ { key: 'GITHUB_COPILOT_ACCESS_TOKEN', label: 'GitHub Copilot', type: 'key' },
630
+ { key: 'OPENAI_CODEX_ACCESS_TOKEN', label: 'OpenAI Codex', type: 'key' },
631
+ { key: 'TELNYX_WEBHOOK_TOKEN', label: 'Telnyx Webhook Token', type: 'key' },
632
+ { key: 'OLLAMA_URL', label: 'Ollama (Local)', type: 'url' },
633
+ { key: 'OPENAI_BASE_URL', label: 'OpenAI Base URL override', type: 'url' },
634
+ { key: 'ANTHROPIC_BASE_URL', label: 'Anthropic Base URL override', type: 'url' },
635
+ { key: 'XAI_BASE_URL', label: 'xAI Base URL override', type: 'url' },
650
636
  ];
651
637
 
652
638
  const ALLOWED_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key));
@@ -682,6 +668,349 @@ router.put('/api/providers', requireAdminAuth, express.json(), (req, res) => {
682
668
  res.json({ ok: true });
683
669
  });
684
670
 
671
+ // --- General server config ---
672
+
673
+ function parseEnvBool(key, defaultVal) {
674
+ const v = (process.env[key] || '').toLowerCase();
675
+ return v ? ['1', 'true', 'yes', 'on'].includes(v) : defaultVal;
676
+ }
677
+
678
+ function parseEnvInt(key, defaultVal) {
679
+ const n = parseInt(process.env[key] || '', 10);
680
+ return Number.isFinite(n) ? n : defaultVal;
681
+ }
682
+
683
+ function cleanLine(v) {
684
+ return String(v ?? '').trim().replace(/[\r\n]/g, '');
685
+ }
686
+
687
+ function persistEnv(key, value) {
688
+ const strVal = String(value);
689
+ upsertEnvValue(ENV_FILE, key, strVal);
690
+ if (strVal === '') {
691
+ delete process.env[key];
692
+ } else {
693
+ process.env[key] = strVal;
694
+ }
695
+ }
696
+
697
+ router.get('/api/config/general', requireAdminAuth, (req, res) => {
698
+ res.json({
699
+ settings: {
700
+ publicUrl: process.env.PUBLIC_URL || '',
701
+ secureCookies: parseEnvBool('SECURE_COOKIES', false),
702
+ neoagentProfile: process.env.NEOAGENT_PROFILE || '',
703
+ allowedOrigins: process.env.ALLOWED_ORIGINS || '',
704
+ meshtasticEnabled: parseEnvBool('MESHTASTIC_ENABLED', true),
705
+ memoryIngestionIntervalMs: parseEnvInt('NEOAGENT_MEMORY_INGESTION_INTERVAL_MS', 600000),
706
+ },
707
+ });
708
+ });
709
+
710
+ router.put('/api/config/general', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
711
+ try {
712
+ const b = req.body || {};
713
+ const publicUrl = cleanLine(b.publicUrl);
714
+ if (publicUrl) {
715
+ try { new URL(publicUrl); } catch {
716
+ return res.status(400).json({ error: 'publicUrl must be a valid URL.' });
717
+ }
718
+ }
719
+ const profile = cleanLine(b.neoagentProfile);
720
+ if (profile && !['prod', 'private'].includes(profile)) {
721
+ return res.status(400).json({ error: 'neoagentProfile must be "prod" or "private".' });
722
+ }
723
+ const allowedOrigins = cleanLine(b.allowedOrigins);
724
+ const intervalMs = parseInt(b.memoryIngestionIntervalMs, 10);
725
+ if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
726
+ return res.status(400).json({ error: 'memoryIngestionIntervalMs must be ≥ 1000.' });
727
+ }
728
+ if (typeof b.secureCookies !== 'boolean') {
729
+ return res.status(400).json({ error: 'secureCookies must be a boolean.' });
730
+ }
731
+ if (typeof b.meshtasticEnabled !== 'boolean') {
732
+ return res.status(400).json({ error: 'meshtasticEnabled must be a boolean.' });
733
+ }
734
+
735
+ persistEnv('PUBLIC_URL', publicUrl);
736
+ persistEnv('SECURE_COOKIES', b.secureCookies ? 'true' : 'false');
737
+ persistEnv('NEOAGENT_PROFILE', profile);
738
+ persistEnv('ALLOWED_ORIGINS', allowedOrigins);
739
+ persistEnv('MESHTASTIC_ENABLED', b.meshtasticEnabled ? 'true' : 'false');
740
+ persistEnv('NEOAGENT_MEMORY_INGESTION_INTERVAL_MS', String(intervalMs));
741
+
742
+ res.json({ ok: true });
743
+ } catch (err) {
744
+ res.status(500).json({ error: err.message });
745
+ }
746
+ });
747
+
748
+ // --- VM runtime config ---
749
+
750
+ router.get('/api/config/vm', requireAdminAuth, (req, res) => {
751
+ res.json({
752
+ settings: {
753
+ vmBaseImageUrl: process.env.NEOAGENT_VM_BASE_IMAGE_URL || '',
754
+ vmBaseImage: process.env.NEOAGENT_VM_BASE_IMAGE || '',
755
+ vmMemoryMb: parseEnvInt('NEOAGENT_VM_MEMORY_MB', 4096),
756
+ vmCpus: parseEnvInt('NEOAGENT_VM_CPUS', 2),
757
+ },
758
+ });
759
+ });
760
+
761
+ router.put('/api/config/vm', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
762
+ try {
763
+ const b = req.body || {};
764
+ const vmBaseImageUrl = cleanLine(b.vmBaseImageUrl);
765
+ const vmBaseImage = cleanLine(b.vmBaseImage);
766
+ const vmMemoryMb = parseInt(b.vmMemoryMb, 10);
767
+ const vmCpus = parseInt(b.vmCpus, 10);
768
+
769
+ if (vmBaseImageUrl) {
770
+ try { new URL(vmBaseImageUrl); } catch {
771
+ return res.status(400).json({ error: 'vmBaseImageUrl must be a valid URL.' });
772
+ }
773
+ }
774
+ if (!Number.isFinite(vmMemoryMb) || vmMemoryMb < 512) {
775
+ return res.status(400).json({ error: 'vmMemoryMb must be ≥ 512.' });
776
+ }
777
+ if (!Number.isFinite(vmCpus) || vmCpus < 1) {
778
+ return res.status(400).json({ error: 'vmCpus must be ≥ 1.' });
779
+ }
780
+
781
+ persistEnv('NEOAGENT_VM_BASE_IMAGE_URL', vmBaseImageUrl);
782
+ persistEnv('NEOAGENT_VM_BASE_IMAGE', vmBaseImage);
783
+ persistEnv('NEOAGENT_VM_MEMORY_MB', String(vmMemoryMb));
784
+ persistEnv('NEOAGENT_VM_CPUS', String(vmCpus));
785
+
786
+ res.json({ ok: true });
787
+ } catch (err) {
788
+ res.status(500).json({ error: err.message });
789
+ }
790
+ });
791
+
792
+ // --- Screen recorder config ---
793
+
794
+ router.get('/api/config/screen-recorder', requireAdminAuth, (req, res) => {
795
+ res.json({
796
+ settings: {
797
+ enabled: parseEnvBool('NEOAGENT_SCREEN_RECORDER_ENABLED', false),
798
+ userId: process.env.NEOAGENT_SCREEN_RECORDER_USER_ID || '',
799
+ intervalMs: parseEnvInt('NEOAGENT_SCREEN_RECORDER_INTERVAL_MS', 10000),
800
+ retentionDays: parseEnvInt('NEOAGENT_SCREEN_RECORDER_RETENTION_DAYS', 7),
801
+ },
802
+ });
803
+ });
804
+
805
+ router.put('/api/config/screen-recorder', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
806
+ try {
807
+ const b = req.body || {};
808
+ if (typeof b.enabled !== 'boolean') {
809
+ return res.status(400).json({ error: 'enabled must be a boolean.' });
810
+ }
811
+ const userId = cleanLine(b.userId);
812
+ if (b.enabled && !userId) {
813
+ return res.status(400).json({ error: 'userId is required when screen recorder is enabled.' });
814
+ }
815
+ const intervalMs = parseInt(b.intervalMs, 10);
816
+ const retentionDays = parseInt(b.retentionDays, 10);
817
+ if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
818
+ return res.status(400).json({ error: 'intervalMs must be ≥ 1000.' });
819
+ }
820
+ if (!Number.isFinite(retentionDays) || retentionDays < 1) {
821
+ return res.status(400).json({ error: 'retentionDays must be ≥ 1.' });
822
+ }
823
+
824
+ persistEnv('NEOAGENT_SCREEN_RECORDER_ENABLED', b.enabled ? 'true' : 'false');
825
+ persistEnv('NEOAGENT_SCREEN_RECORDER_USER_ID', userId);
826
+ persistEnv('NEOAGENT_SCREEN_RECORDER_INTERVAL_MS', String(intervalMs));
827
+ persistEnv('NEOAGENT_SCREEN_RECORDER_RETENTION_DAYS', String(retentionDays));
828
+
829
+ res.json({ ok: true });
830
+ } catch (err) {
831
+ res.status(500).json({ error: err.message });
832
+ }
833
+ });
834
+
835
+ // --- OAuth integrations config ---
836
+
837
+ const OAUTH_INTEGRATIONS = [
838
+ {
839
+ key: 'google',
840
+ label: 'Google Workspace',
841
+ fields: [
842
+ { name: 'clientId', env: 'GOOGLE_OAUTH_CLIENT_ID', secret: false },
843
+ { name: 'clientSecret', env: 'GOOGLE_OAUTH_CLIENT_SECRET', secret: true },
844
+ { name: 'redirectUri', env: 'GOOGLE_OAUTH_REDIRECT_URI', secret: false },
845
+ ],
846
+ },
847
+ {
848
+ key: 'notion',
849
+ label: 'Notion',
850
+ fields: [
851
+ { name: 'clientId', env: 'NOTION_OAUTH_CLIENT_ID', secret: false },
852
+ { name: 'clientSecret', env: 'NOTION_OAUTH_CLIENT_SECRET', secret: true },
853
+ { name: 'redirectUri', env: 'NOTION_OAUTH_REDIRECT_URI', secret: false },
854
+ ],
855
+ },
856
+ {
857
+ key: 'microsoft',
858
+ label: 'Microsoft 365',
859
+ fields: [
860
+ { name: 'clientId', env: 'MICROSOFT_OAUTH_CLIENT_ID', secret: false },
861
+ { name: 'clientSecret', env: 'MICROSOFT_OAUTH_CLIENT_SECRET', secret: true },
862
+ { name: 'redirectUri', env: 'MICROSOFT_OAUTH_REDIRECT_URI', secret: false },
863
+ { name: 'tenantId', env: 'MICROSOFT_OAUTH_TENANT_ID', secret: false },
864
+ ],
865
+ },
866
+ {
867
+ key: 'slack',
868
+ label: 'Slack',
869
+ fields: [
870
+ { name: 'clientId', env: 'SLACK_OAUTH_CLIENT_ID', secret: false },
871
+ { name: 'clientSecret', env: 'SLACK_OAUTH_CLIENT_SECRET', secret: true },
872
+ { name: 'redirectUri', env: 'SLACK_OAUTH_REDIRECT_URI', secret: false },
873
+ ],
874
+ },
875
+ {
876
+ key: 'figma',
877
+ label: 'Figma',
878
+ fields: [
879
+ { name: 'clientId', env: 'FIGMA_OAUTH_CLIENT_ID', secret: false },
880
+ { name: 'clientSecret', env: 'FIGMA_OAUTH_CLIENT_SECRET', secret: true },
881
+ { name: 'redirectUri', env: 'FIGMA_OAUTH_REDIRECT_URI', secret: false },
882
+ ],
883
+ },
884
+ {
885
+ key: 'github',
886
+ label: 'GitHub',
887
+ fields: [
888
+ { name: 'clientId', env: 'GITHUB_OAUTH_CLIENT_ID', secret: false },
889
+ { name: 'clientSecret', env: 'GITHUB_OAUTH_CLIENT_SECRET', secret: true },
890
+ { name: 'redirectUri', env: 'GITHUB_OAUTH_REDIRECT_URI', secret: false },
891
+ ],
892
+ },
893
+ {
894
+ key: 'spotify',
895
+ label: 'Spotify',
896
+ fields: [
897
+ { name: 'clientId', env: 'SPOTIFY_OAUTH_CLIENT_ID', secret: false },
898
+ { name: 'clientSecret', env: 'SPOTIFY_OAUTH_CLIENT_SECRET', secret: true },
899
+ { name: 'redirectUri', env: 'SPOTIFY_OAUTH_REDIRECT_URI', secret: false },
900
+ ],
901
+ },
902
+ {
903
+ key: 'trello',
904
+ label: 'Trello',
905
+ fields: [
906
+ { name: 'apiKey', env: 'TRELLO_API_KEY', secret: false },
907
+ ],
908
+ },
909
+ ];
910
+
911
+ const OAUTH_ENV_KEYS = new Set(
912
+ OAUTH_INTEGRATIONS.flatMap((i) => i.fields.map((f) => f.env))
913
+ );
914
+
915
+ router.get('/api/config/integrations', requireAdminAuth, (req, res) => {
916
+ const integrations = OAUTH_INTEGRATIONS.map(({ key, label, fields }) => {
917
+ const fieldData = fields.map(({ name, env, secret }) => {
918
+ const value = process.env[env] || '';
919
+ if (secret) {
920
+ return { name, secret: true, configured: Boolean(value) };
921
+ }
922
+ return { name, secret: false, value };
923
+ });
924
+ const configured = fields.every(({ env }) => Boolean(process.env[env]));
925
+ return { key, label, configured, fields: fieldData };
926
+ });
927
+
928
+ // Deepgram service settings (not secret, grouped here)
929
+ const deepgram = {
930
+ baseUrl: process.env.DEEPGRAM_BASE_URL || '',
931
+ model: process.env.DEEPGRAM_MODEL || '',
932
+ language: process.env.DEEPGRAM_LANGUAGE || '',
933
+ };
934
+
935
+ res.json({ integrations, deepgram });
936
+ });
937
+
938
+ router.put('/api/config/integrations', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
939
+ try {
940
+ const b = req.body || {};
941
+
942
+ // OAuth integrations
943
+ const patches = b.integrations || {};
944
+ for (const [intKey, fieldValues] of Object.entries(patches)) {
945
+ const integration = OAUTH_INTEGRATIONS.find((i) => i.key === intKey);
946
+ if (!integration) continue;
947
+ for (const [fieldName, value] of Object.entries(fieldValues)) {
948
+ const fieldDef = integration.fields.find((f) => f.name === fieldName);
949
+ if (!fieldDef) continue;
950
+ const cleaned = cleanLine(value);
951
+ if (fieldDef.secret && cleaned === '') continue; // blank = no-change for secrets
952
+ persistEnv(fieldDef.env, cleaned);
953
+ }
954
+ }
955
+
956
+ // Deepgram config
957
+ if (b.deepgram) {
958
+ persistEnv('DEEPGRAM_BASE_URL', cleanLine(b.deepgram.baseUrl));
959
+ persistEnv('DEEPGRAM_MODEL', cleanLine(b.deepgram.model));
960
+ persistEnv('DEEPGRAM_LANGUAGE', cleanLine(b.deepgram.language));
961
+ }
962
+
963
+ res.json({ ok: true });
964
+ } catch (err) {
965
+ res.status(500).json({ error: err.message });
966
+ }
967
+ });
968
+
969
+ // --- Stripe / billing setup config ---
970
+
971
+ router.get('/api/config/billing-setup', requireAdminAuth, (req, res) => {
972
+ const secretKey = process.env.STRIPE_SECRET_KEY || '';
973
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '';
974
+ res.json({
975
+ settings: {
976
+ billingEnabled: parseEnvBool('NEOAGENT_BILLING_ENABLED', false),
977
+ stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
978
+ stripeSecretKeyConfigured: Boolean(secretKey),
979
+ stripeSecretKeyHint: secretKey.length > 8
980
+ ? `${secretKey.slice(0, 7)}${'•'.repeat(4)}${secretKey.slice(-4)}`
981
+ : secretKey ? '•'.repeat(secretKey.length) : '',
982
+ stripeWebhookSecretConfigured: Boolean(webhookSecret),
983
+ trialDays: parseEnvInt('BILLING_TRIAL_DAYS', 14),
984
+ },
985
+ });
986
+ });
987
+
988
+ router.put('/api/config/billing-setup', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
989
+ try {
990
+ const b = req.body || {};
991
+ if (typeof b.billingEnabled !== 'boolean') {
992
+ return res.status(400).json({ error: 'billingEnabled must be a boolean.' });
993
+ }
994
+ const publishableKey = cleanLine(b.stripePublishableKey);
995
+ const secretKey = cleanLine(b.stripeSecretKey || '');
996
+ const webhookSecret = cleanLine(b.stripeWebhookSecret || '');
997
+ const trialDays = parseInt(b.trialDays, 10);
998
+ if (!Number.isFinite(trialDays) || trialDays < 0) {
999
+ return res.status(400).json({ error: 'trialDays must be ≥ 0.' });
1000
+ }
1001
+
1002
+ persistEnv('NEOAGENT_BILLING_ENABLED', b.billingEnabled ? 'true' : 'false');
1003
+ persistEnv('STRIPE_PUBLISHABLE_KEY', publishableKey);
1004
+ if (secretKey) persistEnv('STRIPE_SECRET_KEY', secretKey);
1005
+ if (webhookSecret) persistEnv('STRIPE_WEBHOOK_SECRET', webhookSecret);
1006
+ persistEnv('BILLING_TRIAL_DAYS', String(trialDays));
1007
+
1008
+ res.json({ ok: true });
1009
+ } catch (err) {
1010
+ res.status(500).json({ error: err.message });
1011
+ }
1012
+ });
1013
+
685
1014
  // --- Global default rate limits ---
686
1015
 
687
1016
  router.get('/api/config/rate-limits', requireAdminAuth, (req, res) => {
@@ -755,6 +1084,129 @@ router.put('/api/users/:id/rate-limits', requireAdminAuth, express.json(), (req,
755
1084
  }
756
1085
  });
757
1086
 
1087
+ // --- Billing admin routes (only when billing is enabled) ---
1088
+
1089
+ (function registerBillingRoutes() {
1090
+ const { isBillingEnabled } = require('../services/billing/config');
1091
+ if (!isBillingEnabled()) return;
1092
+
1093
+ const billingPlans = require('../services/billing/plans');
1094
+ const billingSubscriptions = require('../services/billing/subscriptions');
1095
+
1096
+ // Plans CRUD
1097
+ router.get('/api/billing/plans', requireAdminAuth, (req, res) => {
1098
+ try {
1099
+ res.json({ plans: billingPlans.listPlans({ includeInactive: true }) });
1100
+ } catch (err) {
1101
+ res.status(500).json({ error: err.message });
1102
+ }
1103
+ });
1104
+
1105
+ router.post('/api/billing/plans', requireAdminAuth, express.json(), (req, res) => {
1106
+ try {
1107
+ const plan = billingPlans.createPlan(req.body);
1108
+ res.status(201).json({ plan });
1109
+ } catch (err) {
1110
+ res.status(400).json({ error: err.message });
1111
+ }
1112
+ });
1113
+
1114
+ router.put('/api/billing/plans/:id', requireAdminAuth, express.json(), (req, res) => {
1115
+ try {
1116
+ const plan = billingPlans.updatePlan(req.params.id, req.body);
1117
+ if (!plan) return res.status(404).json({ error: 'Plan not found.' });
1118
+ res.json({ plan });
1119
+ } catch (err) {
1120
+ res.status(400).json({ error: err.message });
1121
+ }
1122
+ });
1123
+
1124
+ router.delete('/api/billing/plans/:id', requireAdminAuth, (req, res) => {
1125
+ try {
1126
+ billingPlans.deletePlan(req.params.id);
1127
+ res.json({ ok: true });
1128
+ } catch (err) {
1129
+ res.status(500).json({ error: err.message });
1130
+ }
1131
+ });
1132
+
1133
+ // Subscription browser (all users)
1134
+ router.get('/api/billing/subscriptions', requireAdminAuth, (req, res) => {
1135
+ try {
1136
+ const db = require('../db/database');
1137
+ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
1138
+ const offset = parseInt(req.query.offset || '0', 10);
1139
+ const status = req.query.status || null;
1140
+
1141
+ const where = status ? "WHERE s.status = ?" : "";
1142
+ const params = status ? [status, limit, offset] : [limit, offset];
1143
+
1144
+ const rows = db.prepare(`
1145
+ SELECT s.*, u.username, u.email, u.display_name,
1146
+ p.name AS plan_name, p.price_cents, p.currency
1147
+ FROM user_subscriptions s
1148
+ JOIN users u ON u.id = s.user_id
1149
+ JOIN billing_plans p ON p.id = s.plan_id
1150
+ ${where}
1151
+ ORDER BY s.updated_at DESC
1152
+ LIMIT ? OFFSET ?
1153
+ `).all(...params);
1154
+
1155
+ const total = db.prepare(
1156
+ `SELECT COUNT(*) AS n FROM user_subscriptions s ${where}`,
1157
+ ).get(...(status ? [status] : [])).n;
1158
+
1159
+ res.json({ subscriptions: rows, total, limit, offset });
1160
+ } catch (err) {
1161
+ res.status(500).json({ error: err.message });
1162
+ }
1163
+ });
1164
+
1165
+ // Per-user subscription management
1166
+ router.get('/api/billing/users/:id/subscription', requireAdminAuth, (req, res) => {
1167
+ const userId = parseInt(req.params.id, 10);
1168
+ if (isNaN(userId)) return res.status(400).json({ error: 'Invalid user id.' });
1169
+ try {
1170
+ const sub = billingSubscriptions.getActiveSubscription(userId);
1171
+ res.json({ subscription: sub });
1172
+ } catch (err) {
1173
+ res.status(500).json({ error: err.message });
1174
+ }
1175
+ });
1176
+
1177
+ router.post('/api/billing/users/:id/subscription', requireAdminAuth, express.json(), (req, res) => {
1178
+ const userId = parseInt(req.params.id, 10);
1179
+ if (isNaN(userId)) return res.status(400).json({ error: 'Invalid user id.' });
1180
+ try {
1181
+ const { planId, status } = req.body;
1182
+ if (!planId) return res.status(400).json({ error: 'planId is required.' });
1183
+ const sub = billingSubscriptions.adminSetSubscription(userId, planId, status);
1184
+ res.json({ subscription: sub });
1185
+ } catch (err) {
1186
+ res.status(err.statusCode || 500).json({ error: err.message });
1187
+ }
1188
+ });
1189
+
1190
+ router.delete('/api/billing/users/:id/subscription', requireAdminAuth, (req, res) => {
1191
+ const userId = parseInt(req.params.id, 10);
1192
+ if (isNaN(userId)) return res.status(400).json({ error: 'Invalid user id.' });
1193
+ try {
1194
+ billingSubscriptions.adminCancelSubscription(userId);
1195
+ res.json({ ok: true });
1196
+ } catch (err) {
1197
+ res.status(500).json({ error: err.message });
1198
+ }
1199
+ });
1200
+ })();
1201
+
1202
+ // --- API 404 guard (must come before static files) ---
1203
+ // Prevents unregistered /api/* routes (e.g. billing when disabled) from
1204
+ // falling through to the HTML catch-all and returning a false 200.
1205
+
1206
+ router.all('/api/*', (req, res) => {
1207
+ res.status(404).json({ error: 'Not found.' });
1208
+ });
1209
+
758
1210
  // --- Static files ---
759
1211
 
760
1212
  router.use(express.static(ADMIN_DIR));