neoagent 3.0.1-beta.8 → 3.1.0

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 (100) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.css +78 -0
  42. package/server/admin/admin.js +436 -10
  43. package/server/admin/billing.js +165 -42
  44. package/server/admin/index.html +80 -5
  45. package/server/admin/users.js +15 -15
  46. package/server/db/database.js +125 -20
  47. package/server/http/routes.js +1 -0
  48. package/server/http/static.js +4 -2
  49. package/server/public/.last_build_id +1 -1
  50. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  51. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  52. package/server/public/canvaskit/wimp.wasm +0 -0
  53. package/server/public/flutter_bootstrap.js +2 -2
  54. package/server/public/main.dart.js +91077 -87037
  55. package/server/routes/account.js +53 -0
  56. package/server/routes/admin.js +345 -64
  57. package/server/routes/agents.js +203 -21
  58. package/server/routes/billing.js +5 -0
  59. package/server/routes/browser.js +8 -1
  60. package/server/routes/recordings.js +96 -6
  61. package/server/routes/screenHistory.js +140 -2
  62. package/server/routes/timeline.js +43 -0
  63. package/server/services/account/erasure.js +263 -0
  64. package/server/services/ai/hooks.js +4 -1
  65. package/server/services/ai/loop/agent_engine_core.js +8 -1
  66. package/server/services/ai/loop/blank_recovery.js +36 -0
  67. package/server/services/ai/loop/conversation_loop.js +166 -33
  68. package/server/services/ai/messagingFallback.js +22 -0
  69. package/server/services/ai/rate_limits.js +28 -5
  70. package/server/services/ai/systemPrompt.js +6 -5
  71. package/server/services/ai/taskAnalysis.js +2 -0
  72. package/server/services/ai/toolEvidence.js +15 -0
  73. package/server/services/ai/toolResult.js +40 -0
  74. package/server/services/ai/tools.js +163 -4
  75. package/server/services/android/controller.js +6 -2
  76. package/server/services/billing/plans.js +2 -1
  77. package/server/services/browser/anti_detection.js +192 -0
  78. package/server/services/browser/controller.js +180 -54
  79. package/server/services/desktop/auth.js +3 -0
  80. package/server/services/desktop/registry.js +50 -2
  81. package/server/services/integrations/google/calendar.js +22 -14
  82. package/server/services/manager.js +12 -42
  83. package/server/services/memory/ingestion_chunking.js +268 -0
  84. package/server/services/messaging/telnyx.js +9 -8
  85. package/server/services/recordings/manager.js +60 -27
  86. package/server/services/runtime/backends/local-vm.js +40 -22
  87. package/server/services/runtime/docker-vm-manager.js +157 -266
  88. package/server/services/runtime/guest_bootstrap.js +17 -5
  89. package/server/services/runtime/guest_image.js +188 -0
  90. package/server/services/runtime/manager.js +0 -1
  91. package/server/services/runtime/validation.js +3 -8
  92. package/server/services/social_video/service.js +60 -10
  93. package/server/services/tasks/runtime.js +234 -9
  94. package/server/services/tasks/schedule_utils.js +5 -5
  95. package/server/services/tasks/task_repository.js +13 -0
  96. package/server/services/timeline/service.js +558 -0
  97. package/server/services/wearable/gateway.js +1 -1
  98. package/server/services/websocket.js +21 -3
  99. package/server/services/desktop/screenRecorder.js +0 -292
  100. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -328,4 +328,57 @@ router.delete('/providers/:id', accountLimiter, (req, res) => {
328
328
  }
329
329
  });
330
330
 
331
+ // GDPR Art. 20 — data portability. Returns a structured export of the
332
+ // authenticated user's own data (credential columns redacted).
333
+ router.get('/export', accountLimiter, (req, res) => {
334
+ try {
335
+ const { exportUserData } = require('../services/account/erasure');
336
+ const payload = exportUserData(req.session.userId);
337
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
338
+ res.setHeader(
339
+ 'Content-Disposition',
340
+ `attachment; filename="neoagent-data-export-${req.session.userId}.json"`,
341
+ );
342
+ res.send(JSON.stringify(payload, null, 2));
343
+ } catch (err) {
344
+ sendRouteError(res, err);
345
+ }
346
+ });
347
+
348
+ // GDPR Art. 17 — right to erasure. The user permanently deletes their own
349
+ // account and all associated data. Requires typing the exact username as a
350
+ // destructive-action confirmation (works for password and SSO accounts alike).
351
+ router.post('/delete', accountLimiter, (req, res) => {
352
+ try {
353
+ const userId = req.session.userId;
354
+ const user = db
355
+ .prepare('SELECT username FROM users WHERE id = ?')
356
+ .get(userId);
357
+ if (!user) {
358
+ return res.status(404).json({ error: 'Account not found.' });
359
+ }
360
+ const confirm = String(req.body?.confirmUsername || '').trim();
361
+ if (confirm !== user.username) {
362
+ return res.status(400).json({
363
+ error: 'Confirmation does not match your username.',
364
+ });
365
+ }
366
+
367
+ const { eraseUserData } = require('../services/account/erasure');
368
+ const result = eraseUserData(userId, {
369
+ runtimeManager: req.app.locals.runtimeManager,
370
+ });
371
+
372
+ req.session.destroy(() => {
373
+ res.clearCookie('neoagent.sid');
374
+ res.json({ ok: true, tablesCleared: result.tablesCleared });
375
+ });
376
+ } catch (err) {
377
+ if (err.code === 'NOT_FOUND') {
378
+ return res.status(404).json({ error: 'Account not found.' });
379
+ }
380
+ sendRouteError(res, err);
381
+ }
382
+ });
383
+
331
384
  module.exports = router;
@@ -563,59 +563,16 @@ router.get('/api/users', requireAdminAuth, (req, res) => {
563
563
  });
564
564
 
565
565
  router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
566
- const db = require('../db/database');
567
- const { DATA_DIR } = require('../../runtime/paths');
566
+ const { eraseUserData } = require('../services/account/erasure');
568
567
  const { id } = req.params;
569
568
  if (!id) return res.status(400).json({ error: 'Missing user id' });
570
569
  if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'Invalid user id' });
571
570
  try {
572
- const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
573
- if (!user) return res.status(404).json({ error: 'User not found' });
574
-
575
- // Collect artifact paths before deletion
576
- const artifacts = db.prepare('SELECT storage_path FROM artifacts WHERE user_id = ?').all(id);
577
-
578
- const erase = db.transaction((uid) => {
579
- db.prepare('DELETE FROM conversation_messages WHERE conversation_id IN (SELECT id FROM conversations WHERE user_id = ?)').run(uid);
580
- db.prepare('DELETE FROM conversations WHERE user_id = ?').run(uid);
581
- db.prepare('DELETE FROM agent_steps WHERE run_id IN (SELECT id FROM agent_runs WHERE user_id = ?)').run(uid);
582
- db.prepare('DELETE FROM agent_runs WHERE user_id = ?').run(uid);
583
- db.prepare('DELETE FROM messages WHERE user_id = ?').run(uid);
584
- db.prepare('DELETE FROM memories WHERE user_id = ?').run(uid);
585
- db.prepare('DELETE FROM integration_connections WHERE user_id = ?').run(uid);
586
- db.prepare('DELETE FROM platform_connections WHERE user_id = ?').run(uid);
587
- db.prepare('DELETE FROM mcp_servers WHERE user_id = ?').run(uid);
588
- db.prepare('DELETE FROM user_settings WHERE user_id = ?').run(uid);
589
- db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(uid);
590
- db.prepare('DELETE FROM desktop_companion_devices WHERE user_id = ?').run(uid);
591
- db.prepare('DELETE FROM scheduled_tasks WHERE user_id = ?').run(uid);
592
- db.prepare('DELETE FROM recording_sessions WHERE user_id = ?').run(uid);
593
- db.prepare('DELETE FROM screen_history WHERE user_id = ?').run(uid);
594
- db.prepare('DELETE FROM agents WHERE user_id = ?').run(uid);
595
- db.prepare('DELETE FROM artifacts WHERE user_id = ?').run(uid);
596
- db.prepare('DELETE FROM users WHERE id = ?').run(uid);
597
- });
598
- erase(id);
599
-
600
- // Clean up artifact files on disk — containment-checked
601
- const artifactsRoot = path.resolve(path.join(DATA_DIR, 'artifacts'));
602
- for (const artifact of artifacts) {
603
- try {
604
- const abs = path.resolve(path.isAbsolute(artifact.storage_path)
605
- ? artifact.storage_path
606
- : path.join(DATA_DIR, artifact.storage_path));
607
- if (abs.startsWith(artifactsRoot + path.sep)) {
608
- fs.rmSync(abs, { force: true });
609
- }
610
- } catch {}
611
- }
612
- const userArtifactDir = path.resolve(path.join(DATA_DIR, 'artifacts', id));
613
- if (userArtifactDir.startsWith(artifactsRoot + path.sep)) {
614
- try { fs.rmSync(userArtifactDir, { recursive: true, force: true }); } catch {}
615
- }
616
-
617
- res.json({ ok: true });
571
+ const result = eraseUserData(id, { runtimeManager: req.app.locals.runtimeManager });
572
+ res.json(result);
618
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' });
619
576
  res.status(500).json({ error: String(err.message || err) });
620
577
  }
621
578
  });
@@ -643,10 +600,15 @@ router.post('/api/sql', requireAdminAuth, sqlLimiter, express.json(), (req, res)
643
600
  if (SQL_BLOCKED.test(trimmed)) return res.status(400).json({ error: 'Query contains a blocked SQL keyword' });
644
601
  try {
645
602
  const db = require('../db/database');
646
- const rows = db.prepare(trimmed).all();
647
- 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();
648
610
  const columns = limited.length ? Object.keys(limited[0]) : [];
649
- res.json({ rows: limited, columns, truncated: rows.length > 500, total: rows.length });
611
+ res.json({ rows: limited, columns, truncated, total: truncated ? null : limited.length });
650
612
  } catch (err) {
651
613
  res.status(400).json({ error: String(err.message || err) });
652
614
  }
@@ -655,16 +617,22 @@ router.post('/api/sql', requireAdminAuth, sqlLimiter, express.json(), (req, res)
655
617
  // --- Providers ---
656
618
 
657
619
  const PROVIDERS = [
658
- { key: 'ANTHROPIC_API_KEY', label: 'Anthropic (Claude)', type: 'key' },
659
- { key: 'OPENAI_API_KEY', label: 'OpenAI', type: 'key' },
660
- { key: 'XAI_API_KEY', label: 'xAI (Grok)', type: 'key' },
661
- { key: 'GOOGLE_AI_KEY', label: 'Google (Gemini)', type: 'key' },
662
- { key: 'MINIMAX_API_KEY', label: 'MiniMax', type: 'key' },
663
- { key: 'NVIDIA_API_KEY', label: 'NVIDIA NIM', type: 'key' },
664
- { key: 'OPENROUTER_API_KEY', label: 'OpenRouter', type: 'key' },
665
- { key: 'BRAVE_SEARCH_API_KEY', label: 'Brave Search', type: 'key' },
666
- { key: 'DEEPGRAM_API_KEY', label: 'Deepgram (Voice)', type: 'key' },
667
- { 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' },
668
636
  ];
669
637
 
670
638
  const ALLOWED_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key));
@@ -700,6 +668,306 @@ router.put('/api/providers', requireAdminAuth, express.json(), (req, res) => {
700
668
  res.json({ ok: true });
701
669
  });
702
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
+ // --- OAuth integrations config ---
793
+
794
+ const OAUTH_INTEGRATIONS = [
795
+ {
796
+ key: 'google',
797
+ label: 'Google Workspace',
798
+ fields: [
799
+ { name: 'clientId', env: 'GOOGLE_OAUTH_CLIENT_ID', secret: false },
800
+ { name: 'clientSecret', env: 'GOOGLE_OAUTH_CLIENT_SECRET', secret: true },
801
+ { name: 'redirectUri', env: 'GOOGLE_OAUTH_REDIRECT_URI', secret: false },
802
+ ],
803
+ },
804
+ {
805
+ key: 'notion',
806
+ label: 'Notion',
807
+ fields: [
808
+ { name: 'clientId', env: 'NOTION_OAUTH_CLIENT_ID', secret: false },
809
+ { name: 'clientSecret', env: 'NOTION_OAUTH_CLIENT_SECRET', secret: true },
810
+ { name: 'redirectUri', env: 'NOTION_OAUTH_REDIRECT_URI', secret: false },
811
+ ],
812
+ },
813
+ {
814
+ key: 'microsoft',
815
+ label: 'Microsoft 365',
816
+ fields: [
817
+ { name: 'clientId', env: 'MICROSOFT_OAUTH_CLIENT_ID', secret: false },
818
+ { name: 'clientSecret', env: 'MICROSOFT_OAUTH_CLIENT_SECRET', secret: true },
819
+ { name: 'redirectUri', env: 'MICROSOFT_OAUTH_REDIRECT_URI', secret: false },
820
+ { name: 'tenantId', env: 'MICROSOFT_OAUTH_TENANT_ID', secret: false },
821
+ ],
822
+ },
823
+ {
824
+ key: 'slack',
825
+ label: 'Slack',
826
+ fields: [
827
+ { name: 'clientId', env: 'SLACK_OAUTH_CLIENT_ID', secret: false },
828
+ { name: 'clientSecret', env: 'SLACK_OAUTH_CLIENT_SECRET', secret: true },
829
+ { name: 'redirectUri', env: 'SLACK_OAUTH_REDIRECT_URI', secret: false },
830
+ ],
831
+ },
832
+ {
833
+ key: 'figma',
834
+ label: 'Figma',
835
+ fields: [
836
+ { name: 'clientId', env: 'FIGMA_OAUTH_CLIENT_ID', secret: false },
837
+ { name: 'clientSecret', env: 'FIGMA_OAUTH_CLIENT_SECRET', secret: true },
838
+ { name: 'redirectUri', env: 'FIGMA_OAUTH_REDIRECT_URI', secret: false },
839
+ ],
840
+ },
841
+ {
842
+ key: 'github',
843
+ label: 'GitHub',
844
+ fields: [
845
+ { name: 'clientId', env: 'GITHUB_OAUTH_CLIENT_ID', secret: false },
846
+ { name: 'clientSecret', env: 'GITHUB_OAUTH_CLIENT_SECRET', secret: true },
847
+ { name: 'redirectUri', env: 'GITHUB_OAUTH_REDIRECT_URI', secret: false },
848
+ ],
849
+ },
850
+ {
851
+ key: 'spotify',
852
+ label: 'Spotify',
853
+ fields: [
854
+ { name: 'clientId', env: 'SPOTIFY_OAUTH_CLIENT_ID', secret: false },
855
+ { name: 'clientSecret', env: 'SPOTIFY_OAUTH_CLIENT_SECRET', secret: true },
856
+ { name: 'redirectUri', env: 'SPOTIFY_OAUTH_REDIRECT_URI', secret: false },
857
+ ],
858
+ },
859
+ {
860
+ key: 'trello',
861
+ label: 'Trello',
862
+ fields: [
863
+ { name: 'apiKey', env: 'TRELLO_API_KEY', secret: false },
864
+ ],
865
+ },
866
+ ];
867
+
868
+ const OAUTH_ENV_KEYS = new Set(
869
+ OAUTH_INTEGRATIONS.flatMap((i) => i.fields.map((f) => f.env))
870
+ );
871
+
872
+ router.get('/api/config/integrations', requireAdminAuth, (req, res) => {
873
+ const integrations = OAUTH_INTEGRATIONS.map(({ key, label, fields }) => {
874
+ const fieldData = fields.map(({ name, env, secret }) => {
875
+ const value = process.env[env] || '';
876
+ if (secret) {
877
+ return { name, secret: true, configured: Boolean(value) };
878
+ }
879
+ return { name, secret: false, value };
880
+ });
881
+ const configured = fields.every(({ env }) => Boolean(process.env[env]));
882
+ return { key, label, configured, fields: fieldData };
883
+ });
884
+
885
+ // Deepgram service settings (not secret, grouped here)
886
+ const deepgram = {
887
+ baseUrl: process.env.DEEPGRAM_BASE_URL || '',
888
+ model: process.env.DEEPGRAM_MODEL || '',
889
+ language: process.env.DEEPGRAM_LANGUAGE || '',
890
+ };
891
+
892
+ res.json({ integrations, deepgram });
893
+ });
894
+
895
+ router.put('/api/config/integrations', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
896
+ try {
897
+ const b = req.body || {};
898
+
899
+ // OAuth integrations
900
+ const patches = b.integrations || {};
901
+ for (const [intKey, fieldValues] of Object.entries(patches)) {
902
+ const integration = OAUTH_INTEGRATIONS.find((i) => i.key === intKey);
903
+ if (!integration) continue;
904
+ for (const [fieldName, value] of Object.entries(fieldValues)) {
905
+ const fieldDef = integration.fields.find((f) => f.name === fieldName);
906
+ if (!fieldDef) continue;
907
+ const cleaned = cleanLine(value);
908
+ if (fieldDef.secret && cleaned === '') continue; // blank = no-change for secrets
909
+ persistEnv(fieldDef.env, cleaned);
910
+ }
911
+ }
912
+
913
+ // Deepgram config
914
+ if (b.deepgram) {
915
+ persistEnv('DEEPGRAM_BASE_URL', cleanLine(b.deepgram.baseUrl));
916
+ persistEnv('DEEPGRAM_MODEL', cleanLine(b.deepgram.model));
917
+ persistEnv('DEEPGRAM_LANGUAGE', cleanLine(b.deepgram.language));
918
+ }
919
+
920
+ res.json({ ok: true });
921
+ } catch (err) {
922
+ res.status(500).json({ error: err.message });
923
+ }
924
+ });
925
+
926
+ // --- Stripe / billing setup config ---
927
+
928
+ router.get('/api/config/billing-setup', requireAdminAuth, (req, res) => {
929
+ const secretKey = process.env.STRIPE_SECRET_KEY || '';
930
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '';
931
+ res.json({
932
+ settings: {
933
+ billingEnabled: parseEnvBool('NEOAGENT_BILLING_ENABLED', false),
934
+ stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
935
+ stripeSecretKeyConfigured: Boolean(secretKey),
936
+ stripeSecretKeyHint: secretKey.length > 8
937
+ ? `${secretKey.slice(0, 7)}${'•'.repeat(4)}${secretKey.slice(-4)}`
938
+ : secretKey ? '•'.repeat(secretKey.length) : '',
939
+ stripeWebhookSecretConfigured: Boolean(webhookSecret),
940
+ trialDays: parseEnvInt('BILLING_TRIAL_DAYS', 14),
941
+ },
942
+ });
943
+ });
944
+
945
+ router.put('/api/config/billing-setup', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
946
+ try {
947
+ const b = req.body || {};
948
+ if (typeof b.billingEnabled !== 'boolean') {
949
+ return res.status(400).json({ error: 'billingEnabled must be a boolean.' });
950
+ }
951
+ const publishableKey = cleanLine(b.stripePublishableKey);
952
+ const secretKey = cleanLine(b.stripeSecretKey || '');
953
+ const webhookSecret = cleanLine(b.stripeWebhookSecret || '');
954
+ const trialDays = parseInt(b.trialDays, 10);
955
+ if (!Number.isFinite(trialDays) || trialDays < 0) {
956
+ return res.status(400).json({ error: 'trialDays must be ≥ 0.' });
957
+ }
958
+
959
+ persistEnv('NEOAGENT_BILLING_ENABLED', b.billingEnabled ? 'true' : 'false');
960
+ persistEnv('STRIPE_PUBLISHABLE_KEY', publishableKey);
961
+ if (secretKey) persistEnv('STRIPE_SECRET_KEY', secretKey);
962
+ if (webhookSecret) persistEnv('STRIPE_WEBHOOK_SECRET', webhookSecret);
963
+ persistEnv('BILLING_TRIAL_DAYS', String(trialDays));
964
+
965
+ res.json({ ok: true });
966
+ } catch (err) {
967
+ res.status(500).json({ error: err.message });
968
+ }
969
+ });
970
+
703
971
  // --- Global default rate limits ---
704
972
 
705
973
  router.get('/api/config/rate-limits', requireAdminAuth, (req, res) => {
@@ -853,8 +1121,10 @@ router.put('/api/users/:id/rate-limits', requireAdminAuth, express.json(), (req,
853
1121
 
854
1122
  // Per-user subscription management
855
1123
  router.get('/api/billing/users/:id/subscription', requireAdminAuth, (req, res) => {
1124
+ const userId = parseInt(req.params.id, 10);
1125
+ if (isNaN(userId)) return res.status(400).json({ error: 'Invalid user id.' });
856
1126
  try {
857
- const sub = billingSubscriptions.getActiveSubscription(parseInt(req.params.id, 10));
1127
+ const sub = billingSubscriptions.getActiveSubscription(userId);
858
1128
  res.json({ subscription: sub });
859
1129
  } catch (err) {
860
1130
  res.status(500).json({ error: err.message });
@@ -862,8 +1132,9 @@ router.put('/api/users/:id/rate-limits', requireAdminAuth, express.json(), (req,
862
1132
  });
863
1133
 
864
1134
  router.post('/api/billing/users/:id/subscription', requireAdminAuth, express.json(), (req, res) => {
1135
+ const userId = parseInt(req.params.id, 10);
1136
+ if (isNaN(userId)) return res.status(400).json({ error: 'Invalid user id.' });
865
1137
  try {
866
- const userId = parseInt(req.params.id, 10);
867
1138
  const { planId, status } = req.body;
868
1139
  if (!planId) return res.status(400).json({ error: 'planId is required.' });
869
1140
  const sub = billingSubscriptions.adminSetSubscription(userId, planId, status);
@@ -874,8 +1145,10 @@ router.put('/api/users/:id/rate-limits', requireAdminAuth, express.json(), (req,
874
1145
  });
875
1146
 
876
1147
  router.delete('/api/billing/users/:id/subscription', requireAdminAuth, (req, res) => {
1148
+ const userId = parseInt(req.params.id, 10);
1149
+ if (isNaN(userId)) return res.status(400).json({ error: 'Invalid user id.' });
877
1150
  try {
878
- billingSubscriptions.adminCancelSubscription(parseInt(req.params.id, 10));
1151
+ billingSubscriptions.adminCancelSubscription(userId);
879
1152
  res.json({ ok: true });
880
1153
  } catch (err) {
881
1154
  res.status(500).json({ error: err.message });
@@ -883,6 +1156,14 @@ router.put('/api/users/:id/rate-limits', requireAdminAuth, express.json(), (req,
883
1156
  });
884
1157
  })();
885
1158
 
1159
+ // --- API 404 guard (must come before static files) ---
1160
+ // Prevents unregistered /api/* routes (e.g. billing when disabled) from
1161
+ // falling through to the HTML catch-all and returning a false 200.
1162
+
1163
+ router.all('/api/*', (req, res) => {
1164
+ res.status(404).json({ error: 'Not found.' });
1165
+ });
1166
+
886
1167
  // --- Static files ---
887
1168
 
888
1169
  router.use(express.static(ADMIN_DIR));