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.
- package/.env.example +19 -0
- package/README.md +20 -3
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +224 -0
- package/docs/configuration.md +22 -0
- package/docs/getting-started.md +10 -8
- package/docs/index.md +1 -0
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +4 -1
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +316 -0
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1594 -224
- package/flutter_app/lib/main_controller.dart +263 -26
- package/flutter_app/lib/main_devices.dart +1 -1
- package/flutter_app/lib/main_install.dart +1147 -0
- package/flutter_app/lib/main_navigation.dart +12 -0
- package/flutter_app/lib/main_operations.dart +134 -9
- package/flutter_app/lib/main_settings.dart +148 -52
- package/flutter_app/lib/main_shared.dart +1 -0
- package/flutter_app/lib/src/backend_client.dart +86 -1
- package/landing/index.html +2 -2
- package/lib/manager.js +184 -66
- package/lib/schema_migrations.js +84 -0
- package/package.json +10 -4
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +511 -23
- package/server/admin/billing.js +415 -0
- package/server/admin/index.html +120 -13
- package/server/admin/users.js +15 -15
- package/server/db/database.js +13 -21
- package/server/db/sessions_db.js +8 -0
- package/server/http/middleware.js +4 -4
- package/server/http/routes.js +9 -0
- package/server/http/static.js +4 -2
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +89347 -85449
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +515 -63
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +127 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/routes/triggers.js +2 -2
- package/server/services/account/erasure.js +263 -0
- package/server/services/account/sessions.js +1 -9
- package/server/services/ai/loop/agent_engine_core.js +42 -0
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/completion_judge.js +42 -0
- package/server/services/ai/loop/conversation_loop.js +248 -34
- package/server/services/ai/loop/progress_monitor.js +6 -6
- package/server/services/ai/loopPolicy.js +37 -44
- package/server/services/ai/messagingFallback.js +25 -1
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/preModelCompaction.js +25 -5
- package/server/services/ai/rate_limits.js +28 -4
- package/server/services/ai/systemPrompt.js +23 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/tools.js +231 -20
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +121 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/browser/contentExtractor.js +629 -0
- package/server/services/browser/controller.js +4 -8
- package/server/services/desktop/gateway.js +7 -4
- package/server/services/integrations/google/calendar.js +30 -2
- package/server/services/integrations/secrets.js +7 -4
- package/server/services/manager.js +11 -0
- package/server/services/messaging/automation.js +1 -1
- package/server/services/messaging/telnyx.js +12 -11
- package/server/services/messaging/whatsapp.js +1 -1
- package/server/services/recordings/manager.js +13 -7
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +182 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/tasks/runtime.js +103 -15
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/voice/runtimeManager.js +19 -10
- package/server/services/wearable/gateway.js +8 -5
- package/server/services/websocket.js +26 -8
- package/server/services/workspace/manager.js +37 -3
package/server/routes/admin.js
CHANGED
|
@@ -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
|
|
68
|
-
const expectedPassword = process.env.ADMIN_PASSWORD
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
564
|
-
|
|
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
|
|
629
|
-
const
|
|
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:
|
|
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',
|
|
641
|
-
{ key: 'OPENAI_API_KEY',
|
|
642
|
-
{ key: 'XAI_API_KEY',
|
|
643
|
-
{ key: 'GOOGLE_AI_KEY',
|
|
644
|
-
{ key: 'MINIMAX_API_KEY',
|
|
645
|
-
{ key: 'NVIDIA_API_KEY',
|
|
646
|
-
{ key: 'OPENROUTER_API_KEY',
|
|
647
|
-
{ key: 'BRAVE_SEARCH_API_KEY',
|
|
648
|
-
{ key: 'DEEPGRAM_API_KEY',
|
|
649
|
-
{ key: '
|
|
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));
|