neoagent 2.4.4-beta.0 → 2.4.4-beta.11
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 +17 -0
- package/README.md +9 -3
- package/docs/capabilities.md +16 -7
- package/docs/configuration.md +1 -0
- package/docs/getting-started.md +6 -0
- package/docs/index.md +1 -0
- package/docs/security-boundaries.md +122 -0
- package/docs/supermemory-memory-review.md +852 -0
- package/flutter_app/lib/features/memory/views/retrieval_inspector_view.dart +128 -0
- package/flutter_app/lib/main.dart +3 -0
- package/flutter_app/lib/main_account_settings.dart +79 -15
- package/flutter_app/lib/main_app_shell.dart +22 -0
- package/flutter_app/lib/main_chat.dart +155 -8
- package/flutter_app/lib/main_controller.dart +74 -5
- package/flutter_app/lib/main_devices.dart +9 -3
- package/flutter_app/lib/main_models.dart +32 -0
- package/flutter_app/lib/main_operations.dart +13 -0
- package/flutter_app/lib/main_security.dart +967 -0
- package/flutter_app/lib/main_settings.dart +56 -0
- package/flutter_app/lib/src/backend_client.dart +60 -3
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
- package/flutter_app/pubspec.lock +32 -0
- package/flutter_app/pubspec.yaml +1 -0
- package/lib/install_helpers.js +1 -0
- package/lib/manager.js +63 -1
- package/lib/schema_migrations.js +262 -0
- package/package.json +4 -2
- package/server/admin/admin.js +151 -0
- package/server/admin/index.html +55 -3
- package/server/db/database.js +3 -0
- package/server/http/routes.js +2 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +86 -0
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +82243 -80308
- package/server/routes/account.js +2 -23
- package/server/routes/admin.js +18 -2
- package/server/routes/agents.js +5 -1
- package/server/routes/memory.js +41 -4
- package/server/routes/security.js +112 -0
- package/server/services/account/service_email_settings.js +167 -0
- package/server/services/ai/engine.js +269 -27
- package/server/services/ai/rate_limits.js +150 -0
- package/server/services/ai/systemPrompt.js +26 -3
- package/server/services/ai/tools.js +11 -8
- package/server/services/cli/shell_worker.js +135 -0
- package/server/services/cli/shell_worker_pool.js +125 -0
- package/server/services/integrations/google/gmail.js +7 -7
- package/server/services/manager.js +20 -1
- package/server/services/memory/consolidation.js +111 -0
- package/server/services/memory/embedding_index.js +175 -0
- package/server/services/memory/embeddings.js +22 -2
- package/server/services/memory/evaluation.js +187 -0
- package/server/services/memory/ingestion_chunking.js +191 -0
- package/server/services/memory/ingestion_documents.js +96 -26
- package/server/services/memory/intelligence.js +3 -1
- package/server/services/memory/manager.js +855 -43
- package/server/services/memory/policy.js +0 -40
- package/server/services/memory/retrieval_reasoning.js +191 -0
- package/server/services/runtime/manager.js +7 -0
- package/server/services/security/approval_gate_service.js +93 -0
- package/server/services/security/tool_categories.js +105 -0
- package/server/services/security/tool_policy_service.js +92 -0
- package/server/services/security/tool_security_hook.js +77 -0
- package/server/services/websocket.js +5 -1
package/server/routes/account.js
CHANGED
|
@@ -38,6 +38,7 @@ const {
|
|
|
38
38
|
approveChallenge,
|
|
39
39
|
resolveChallengeForApproval,
|
|
40
40
|
} = require('../services/account/qr_login');
|
|
41
|
+
const { getRateLimitSnapshot } = require('../services/ai/rate_limits');
|
|
41
42
|
|
|
42
43
|
const accountLimiter = rateLimit({
|
|
43
44
|
windowMs: 15 * 60 * 1000,
|
|
@@ -112,29 +113,7 @@ router.get('/', (req, res) => {
|
|
|
112
113
|
|
|
113
114
|
router.get('/usage', (req, res) => {
|
|
114
115
|
try {
|
|
115
|
-
|
|
116
|
-
const userLimits = db.prepare('SELECT rate_limit_4h, rate_limit_weekly FROM users WHERE id = ?').get(userId);
|
|
117
|
-
const h4Tokens = db.prepare("SELECT COALESCE(SUM(total_tokens), 0) as t FROM agent_runs WHERE user_id = ? AND created_at > datetime('now', '-4 hours')").get(userId).t;
|
|
118
|
-
const weeklyTokens = db.prepare("SELECT COALESCE(SUM(total_tokens), 0) as t FROM agent_runs WHERE user_id = ? AND created_at > datetime('now', '-7 days')").get(userId).t;
|
|
119
|
-
|
|
120
|
-
const globalLimit4h = process.env.NEOAGENT_RATE_LIMIT_4H ? parseInt(process.env.NEOAGENT_RATE_LIMIT_4H, 10) : null;
|
|
121
|
-
const globalLimitWeekly = process.env.NEOAGENT_RATE_LIMIT_WEEKLY ? parseInt(process.env.NEOAGENT_RATE_LIMIT_WEEKLY, 10) : null;
|
|
122
|
-
|
|
123
|
-
const custom4h = userLimits?.rate_limit_4h ?? null;
|
|
124
|
-
const customWeekly = userLimits?.rate_limit_weekly ?? null;
|
|
125
|
-
|
|
126
|
-
res.json({
|
|
127
|
-
limits: {
|
|
128
|
-
fourHour: custom4h ?? globalLimit4h,
|
|
129
|
-
weekly: customWeekly ?? globalLimitWeekly,
|
|
130
|
-
fourHourIsCustom: custom4h !== null,
|
|
131
|
-
weeklyIsCustom: customWeekly !== null,
|
|
132
|
-
},
|
|
133
|
-
usage: {
|
|
134
|
-
fourHour: h4Tokens,
|
|
135
|
-
weekly: weeklyTokens,
|
|
136
|
-
}
|
|
137
|
-
});
|
|
116
|
+
res.json(getRateLimitSnapshot(req.session.userId));
|
|
138
117
|
} catch (err) {
|
|
139
118
|
sendRouteError(res, err);
|
|
140
119
|
}
|
package/server/routes/admin.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
const { APP_DIR, ENV_FILE, upsertEnvValue } = require('../../runtime/paths');
|
|
19
19
|
const { isManagedDeployment } = require('../utils/deployment');
|
|
20
20
|
const rateLimit = require('express-rate-limit');
|
|
21
|
+
const { configuredDefaultLimits } = require('../services/ai/rate_limits');
|
|
21
22
|
|
|
22
23
|
const router = express.Router();
|
|
23
24
|
const ADMIN_DIR = path.join(__dirname, '..', 'admin');
|
|
@@ -324,6 +325,20 @@ const sqlLimiter = rateLimit({
|
|
|
324
325
|
message: { error: 'Too many SQL queries, slow down' },
|
|
325
326
|
});
|
|
326
327
|
|
|
328
|
+
router.get('/api/config/email', requireAdminAuth, (req, res) => {
|
|
329
|
+
const { getAdminEmailSettings } = require('../services/account/service_email_settings');
|
|
330
|
+
res.json(getAdminEmailSettings());
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
router.put('/api/config/email', requireAdminAuth, settingsLimiter, express.json(), (req, res) => {
|
|
334
|
+
try {
|
|
335
|
+
const { updateAdminEmailSettings } = require('../services/account/service_email_settings');
|
|
336
|
+
res.json({ ok: true, ...updateAdminEmailSettings(req.body) });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
res.status(err.statusCode || 500).json({ error: err.message });
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
327
342
|
// --- Access settings (signup toggle + API key) ---
|
|
328
343
|
|
|
329
344
|
router.get('/api/settings', requireAdminAuth, (req, res) => {
|
|
@@ -670,9 +685,10 @@ router.put('/api/providers', requireAdminAuth, express.json(), (req, res) => {
|
|
|
670
685
|
// --- Global default rate limits ---
|
|
671
686
|
|
|
672
687
|
router.get('/api/config/rate-limits', requireAdminAuth, (req, res) => {
|
|
688
|
+
const defaults = configuredDefaultLimits();
|
|
673
689
|
res.json({
|
|
674
|
-
rate_limit_4h:
|
|
675
|
-
rate_limit_weekly:
|
|
690
|
+
rate_limit_4h: defaults.fourHour,
|
|
691
|
+
rate_limit_weekly: defaults.weekly,
|
|
676
692
|
});
|
|
677
693
|
});
|
|
678
694
|
|
package/server/routes/agents.js
CHANGED
|
@@ -130,7 +130,11 @@ router.post('/', async (req, res) => {
|
|
|
130
130
|
|
|
131
131
|
res.json(result);
|
|
132
132
|
} catch (err) {
|
|
133
|
-
res.status(500).json({
|
|
133
|
+
res.status(err?.statusCode || err?.status || 500).json({
|
|
134
|
+
error: sanitizeError(err),
|
|
135
|
+
code: err?.code,
|
|
136
|
+
rateLimit: err?.rateLimit,
|
|
137
|
+
});
|
|
134
138
|
}
|
|
135
139
|
});
|
|
136
140
|
|
package/server/routes/memory.js
CHANGED
|
@@ -28,6 +28,14 @@ const transferImportLimiter = rateLimit({
|
|
|
28
28
|
legacyHeaders: false,
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
const memorySearchLimiter = rateLimit({
|
|
32
|
+
windowMs: 1 * 60 * 1000,
|
|
33
|
+
max: 60,
|
|
34
|
+
message: { error: 'Too many memory search requests, try again later' },
|
|
35
|
+
standardHeaders: true,
|
|
36
|
+
legacyHeaders: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
31
39
|
const MAX_TRANSFER_TEXT_BYTES = 60 * 1024;
|
|
32
40
|
|
|
33
41
|
function normalizeMemoryIds(value) {
|
|
@@ -378,20 +386,49 @@ router.post('/memories/bulk-archive', (req, res) => {
|
|
|
378
386
|
});
|
|
379
387
|
|
|
380
388
|
// Semantic recall (search)
|
|
381
|
-
router.post('/memories/recall', async (req, res) => {
|
|
389
|
+
router.post('/memories/recall', memorySearchLimiter, async (req, res) => {
|
|
382
390
|
const mm = req.app.locals.memoryManager;
|
|
383
391
|
const userId = req.session.userId;
|
|
384
392
|
const agentId = resolveAgentId(userId, getAgentIdFromRequest(req));
|
|
385
393
|
const { query, limit = 8 } = req.body;
|
|
386
394
|
if (!query) return res.status(400).json({ error: 'query is required' });
|
|
395
|
+
|
|
396
|
+
let parsedLimit = Number.parseInt(limit, 10);
|
|
397
|
+
if (!Number.isFinite(parsedLimit) || Number.isNaN(parsedLimit)) {
|
|
398
|
+
return res.status(400).json({ error: 'limit must be an integer between 1 and 100' });
|
|
399
|
+
}
|
|
400
|
+
parsedLimit = Math.max(1, Math.min(100, parsedLimit));
|
|
401
|
+
|
|
387
402
|
try {
|
|
388
|
-
const results = await mm.recallMemory(userId, query,
|
|
403
|
+
const results = await mm.recallMemory(userId, query, parsedLimit, { agentId });
|
|
389
404
|
res.json(results);
|
|
390
405
|
} catch (err) {
|
|
391
406
|
res.status(500).json({ error: sanitizeError(err) });
|
|
392
407
|
}
|
|
393
408
|
});
|
|
394
409
|
|
|
410
|
+
// Semantic recall inspection (UI debug)
|
|
411
|
+
router.post('/memories/inspect', memorySearchLimiter, async (req, res) => {
|
|
412
|
+
const mm = req.app.locals.memoryManager;
|
|
413
|
+
const userId = req.session.userId;
|
|
414
|
+
const agentId = resolveAgentId(userId, getAgentIdFromRequest(req));
|
|
415
|
+
const { query, limit = 20 } = req.body;
|
|
416
|
+
if (!query) return res.status(400).json({ error: 'query is required' });
|
|
417
|
+
|
|
418
|
+
let parsedLimit = Number.parseInt(limit, 10);
|
|
419
|
+
if (!Number.isFinite(parsedLimit) || Number.isNaN(parsedLimit)) {
|
|
420
|
+
return res.status(400).json({ error: 'limit must be an integer between 1 and 100' });
|
|
421
|
+
}
|
|
422
|
+
parsedLimit = Math.max(1, Math.min(100, parsedLimit));
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const results = await mm.recallMemory(userId, query, parsedLimit, { agentId });
|
|
426
|
+
res.json({ results });
|
|
427
|
+
} catch (err) {
|
|
428
|
+
res.status(500).json({ error: sanitizeError(err) });
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
395
432
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
396
433
|
// Core Memory
|
|
397
434
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -457,7 +494,7 @@ router.post('/transfer-import', transferImportLimiter, async (req, res) => {
|
|
|
457
494
|
if (applyCoreMemory && parsed.coreEntries) {
|
|
458
495
|
for (const [key, value] of Object.entries(parsed.coreEntries)) {
|
|
459
496
|
if (!key || key === 'active_context') continue;
|
|
460
|
-
mm.updateCore(userId, key, value, { agentId
|
|
497
|
+
mm.updateCore(userId, key, value, { agentId });
|
|
461
498
|
coreUpdatedCount += 1;
|
|
462
499
|
}
|
|
463
500
|
}
|
|
@@ -489,7 +526,7 @@ router.put('/core/:key', (req, res) => {
|
|
|
489
526
|
if (req.params.key === 'active_context') {
|
|
490
527
|
return res.status(400).json({ error: 'active_context is no longer a supported core memory key' });
|
|
491
528
|
}
|
|
492
|
-
mm.updateCore(userId, req.params.key, value, { agentId
|
|
529
|
+
mm.updateCore(userId, req.params.key, value, { agentId });
|
|
493
530
|
res.json({ success: true });
|
|
494
531
|
});
|
|
495
532
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
const { requireAuth } = require('../middleware/auth');
|
|
6
|
+
const db = require('../db/database');
|
|
7
|
+
const { TOOL_CATEGORIES, getCategoryForTool } = require('../services/security/tool_categories');
|
|
8
|
+
|
|
9
|
+
router.use(requireAuth);
|
|
10
|
+
|
|
11
|
+
// ── Security mode ─────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
// GET /api/security/mode — current global security mode
|
|
14
|
+
router.get('/mode', (req, res) => {
|
|
15
|
+
const mode = req.app.locals.toolPolicyService.getSecurityMode(req.session.userId);
|
|
16
|
+
res.json({ mode });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// PUT /api/security/mode — update global security mode
|
|
20
|
+
router.put('/mode', (req, res) => {
|
|
21
|
+
const { mode } = req.body;
|
|
22
|
+
if (!mode) return res.status(400).json({ error: 'mode is required' });
|
|
23
|
+
try {
|
|
24
|
+
req.app.locals.toolPolicyService.setSecurityMode(req.session.userId, mode);
|
|
25
|
+
res.json({ ok: true, mode });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
res.status(400).json({ error: err.message });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ── Per-category policies ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
// GET /api/security/policies — all category policies + current mode
|
|
34
|
+
router.get('/policies', (req, res) => {
|
|
35
|
+
const { toolPolicyService } = req.app.locals;
|
|
36
|
+
const policies = toolPolicyService.getPolicies(req.session.userId);
|
|
37
|
+
const mode = toolPolicyService.getSecurityMode(req.session.userId);
|
|
38
|
+
res.json({ policies, mode, categories: Object.keys(TOOL_CATEGORIES) });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// PUT /api/security/policies — update a single category policy
|
|
42
|
+
router.put('/policies', (req, res) => {
|
|
43
|
+
const { category, policy } = req.body;
|
|
44
|
+
if (!category || !policy) {
|
|
45
|
+
return res.status(400).json({ error: 'category and policy are required' });
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
req.app.locals.toolPolicyService.setPolicy(req.session.userId, category, policy);
|
|
49
|
+
res.json({ ok: true, category, policy });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
res.status(400).json({ error: err.message });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── Approval decisions ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
// POST /api/security/approvals/:approvalId — resolve a pending approval
|
|
58
|
+
router.post('/approvals/:approvalId', (req, res) => {
|
|
59
|
+
const { approvalId } = req.params;
|
|
60
|
+
const { decision, scope, runId, toolName, toolArgs } = req.body;
|
|
61
|
+
|
|
62
|
+
if (!decision || !['approved', 'denied'].includes(decision)) {
|
|
63
|
+
return res.status(400).json({ error: 'decision must be approved or denied' });
|
|
64
|
+
}
|
|
65
|
+
const normalizedScope = ['once', 'session', 'always'].includes(scope) ? scope : 'once';
|
|
66
|
+
|
|
67
|
+
// 'always' scope: also persist the policy so this category is allowed going forward
|
|
68
|
+
if (decision === 'approved' && normalizedScope === 'always' && toolName) {
|
|
69
|
+
try {
|
|
70
|
+
const category = getCategoryForTool(toolName, toolArgs ?? {});
|
|
71
|
+
if (category) {
|
|
72
|
+
req.app.locals.toolPolicyService.setPolicy(req.session.userId, category, 'allow');
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { approvalGateService } = req.app.locals;
|
|
78
|
+
const resolved = approvalGateService.resolve(
|
|
79
|
+
approvalId,
|
|
80
|
+
req.session.userId,
|
|
81
|
+
runId || null,
|
|
82
|
+
toolName || null,
|
|
83
|
+
toolArgs || {},
|
|
84
|
+
decision,
|
|
85
|
+
normalizedScope,
|
|
86
|
+
);
|
|
87
|
+
if (!resolved) {
|
|
88
|
+
return res.status(404).json({ error: 'Approval not found or already resolved' });
|
|
89
|
+
}
|
|
90
|
+
res.json({ ok: true, approvalId, decision, scope: normalizedScope });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── Audit log ─────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
// GET /api/security/approval-log — paginated audit log
|
|
96
|
+
router.get('/approval-log', (req, res) => {
|
|
97
|
+
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
|
98
|
+
const offset = Number(req.query.offset) || 0;
|
|
99
|
+
const rows = db.prepare(`
|
|
100
|
+
SELECT id, run_id, tool_name, tool_args_json, decision, scope, decided_at
|
|
101
|
+
FROM tool_approval_log
|
|
102
|
+
WHERE user_id = ?
|
|
103
|
+
ORDER BY decided_at DESC
|
|
104
|
+
LIMIT ? OFFSET ?
|
|
105
|
+
`).all(req.session.userId, limit, offset);
|
|
106
|
+
const total = db.prepare(
|
|
107
|
+
'SELECT COUNT(*) as count FROM tool_approval_log WHERE user_id = ?'
|
|
108
|
+
).get(req.session.userId)?.count ?? 0;
|
|
109
|
+
res.json({ log: rows, total, limit, offset });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
module.exports = router;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { ENV_FILE, upsertEnvValue } = require('../../../runtime/paths');
|
|
4
|
+
|
|
5
|
+
const STRING_FIELDS = Object.freeze({
|
|
6
|
+
from: 'NEOAGENT_EMAIL_FROM',
|
|
7
|
+
smtpHost: 'NEOAGENT_EMAIL_SMTP_HOST',
|
|
8
|
+
smtpUser: 'NEOAGENT_EMAIL_SMTP_USER',
|
|
9
|
+
replyTo: 'NEOAGENT_EMAIL_REPLY_TO',
|
|
10
|
+
brandName: 'NEOAGENT_EMAIL_BRAND_NAME',
|
|
11
|
+
supportUrl: 'NEOAGENT_EMAIL_SUPPORT_URL',
|
|
12
|
+
publicUrl: 'NEOAGENT_EMAIL_PUBLIC_URL',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const BOOLEAN_FIELDS = Object.freeze({
|
|
16
|
+
smtpSecure: ['NEOAGENT_EMAIL_SMTP_SECURE', false],
|
|
17
|
+
smtpRequireTls: ['NEOAGENT_EMAIL_SMTP_REQUIRE_TLS', true],
|
|
18
|
+
smtpRejectUnauthorized: ['NEOAGENT_EMAIL_SMTP_REJECT_UNAUTHORIZED', true],
|
|
19
|
+
requireSignupConfirmation: ['NEOAGENT_EMAIL_REQUIRE_SIGNUP_CONFIRMATION', true],
|
|
20
|
+
requireEmailChangeConfirmation: ['NEOAGENT_EMAIL_REQUIRE_EMAIL_CHANGE_CONFIRMATION', true],
|
|
21
|
+
notifyUnusualLogin: ['NEOAGENT_EMAIL_NOTIFY_UNUSUAL_LOGIN', true],
|
|
22
|
+
notifyAccountChanges: ['NEOAGENT_EMAIL_NOTIFY_ACCOUNT_CHANGES', true],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function envString(env, key) {
|
|
26
|
+
return String(env[key] || '').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function envBoolean(env, key, defaultValue) {
|
|
30
|
+
const value = envString(env, key).toLowerCase();
|
|
31
|
+
if (!value) return defaultValue;
|
|
32
|
+
return ['1', 'true', 'yes', 'on'].includes(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function envPositiveInteger(env, key, defaultValue) {
|
|
36
|
+
const value = Number(envString(env, key));
|
|
37
|
+
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanSingleLine(value, label) {
|
|
41
|
+
const cleaned = String(value ?? '').trim();
|
|
42
|
+
if (/[\r\n]/.test(cleaned)) {
|
|
43
|
+
const error = new Error(`${label} must be a single line.`);
|
|
44
|
+
error.statusCode = 400;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return cleaned;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireBoolean(value, label) {
|
|
51
|
+
if (typeof value !== 'boolean') {
|
|
52
|
+
const error = new Error(`${label} must be true or false.`);
|
|
53
|
+
error.statusCode = 400;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireInteger(value, label, { min, max }) {
|
|
60
|
+
const number = Number(value);
|
|
61
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
62
|
+
const error = new Error(`${label} must be an integer from ${min} to ${max}.`);
|
|
63
|
+
error.statusCode = 400;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
return number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requireHttpUrl(value, label) {
|
|
70
|
+
const cleaned = cleanSingleLine(value, label);
|
|
71
|
+
if (!cleaned) return '';
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(cleaned);
|
|
74
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('invalid protocol');
|
|
75
|
+
} catch {
|
|
76
|
+
const error = new Error(`${label} must be an HTTP or HTTPS URL.`);
|
|
77
|
+
error.statusCode = 400;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
return cleaned.replace(/\/$/, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function encodeEnvValue(value) {
|
|
84
|
+
return JSON.stringify(String(value));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function persistValue(envFile, env, key, value) {
|
|
88
|
+
upsertEnvValue(envFile, key, encodeEnvValue(value));
|
|
89
|
+
if (value === '') {
|
|
90
|
+
delete env[key];
|
|
91
|
+
} else {
|
|
92
|
+
env[key] = String(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getAdminEmailSettings({ env = process.env } = {}) {
|
|
97
|
+
const smtpPort = envPositiveInteger(env, 'NEOAGENT_EMAIL_SMTP_PORT', 587);
|
|
98
|
+
const settings = {
|
|
99
|
+
smtpPort,
|
|
100
|
+
tokenTtlHours: envPositiveInteger(env, 'NEOAGENT_EMAIL_TOKEN_TTL_HOURS', 24),
|
|
101
|
+
smtpPasswordConfigured: Boolean(envString(env, 'NEOAGENT_EMAIL_SMTP_PASS')),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (const [name, key] of Object.entries(STRING_FIELDS)) {
|
|
105
|
+
settings[name] = envString(env, key);
|
|
106
|
+
}
|
|
107
|
+
for (const [name, [key, defaultValue]] of Object.entries(BOOLEAN_FIELDS)) {
|
|
108
|
+
let effectiveDefault = defaultValue;
|
|
109
|
+
if (name === 'smtpSecure') effectiveDefault = smtpPort === 465;
|
|
110
|
+
if (name === 'smtpRequireTls') effectiveDefault = !settings.smtpSecure;
|
|
111
|
+
settings[name] = envBoolean(env, key, effectiveDefault);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const missing = [];
|
|
115
|
+
if (!settings.from) missing.push('Sender address');
|
|
116
|
+
if (!settings.smtpHost) missing.push('SMTP host');
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
configured: missing.length === 0,
|
|
120
|
+
missing,
|
|
121
|
+
settings,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function updateAdminEmailSettings(body, { env = process.env, envFile = ENV_FILE } = {}) {
|
|
126
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
127
|
+
const error = new Error('Email settings must be an object.');
|
|
128
|
+
error.statusCode = 400;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const values = {};
|
|
133
|
+
for (const name of Object.keys(STRING_FIELDS)) {
|
|
134
|
+
values[name] = name === 'supportUrl' || name === 'publicUrl'
|
|
135
|
+
? requireHttpUrl(body[name], name === 'supportUrl' ? 'Support URL' : 'Public URL')
|
|
136
|
+
: cleanSingleLine(body[name], name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
values.smtpPort = requireInteger(body.smtpPort, 'SMTP port', { min: 1, max: 65535 });
|
|
140
|
+
values.tokenTtlHours = requireInteger(body.tokenTtlHours, 'Token lifetime', { min: 1, max: 8760 });
|
|
141
|
+
|
|
142
|
+
for (const name of Object.keys(BOOLEAN_FIELDS)) {
|
|
143
|
+
values[name] = requireBoolean(body[name], name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const smtpPassword = cleanSingleLine(body.smtpPassword, 'SMTP password');
|
|
147
|
+
const clearSmtpPassword = body.clearSmtpPassword === true;
|
|
148
|
+
|
|
149
|
+
for (const [name, key] of Object.entries(STRING_FIELDS)) {
|
|
150
|
+
persistValue(envFile, env, key, values[name]);
|
|
151
|
+
}
|
|
152
|
+
persistValue(envFile, env, 'NEOAGENT_EMAIL_SMTP_PORT', values.smtpPort);
|
|
153
|
+
persistValue(envFile, env, 'NEOAGENT_EMAIL_TOKEN_TTL_HOURS', values.tokenTtlHours);
|
|
154
|
+
for (const [name, [key]] of Object.entries(BOOLEAN_FIELDS)) {
|
|
155
|
+
persistValue(envFile, env, key, values[name]);
|
|
156
|
+
}
|
|
157
|
+
if (smtpPassword || clearSmtpPassword) {
|
|
158
|
+
persistValue(envFile, env, 'NEOAGENT_EMAIL_SMTP_PASS', clearSmtpPassword ? '' : smtpPassword);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return getAdminEmailSettings({ env });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
getAdminEmailSettings,
|
|
166
|
+
updateAdminEmailSettings,
|
|
167
|
+
};
|