neoagent 1.0.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 (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +42 -0
  4. package/bin/neoagent.js +8 -0
  5. package/com.neoagent.plist +45 -0
  6. package/docs/configuration.md +45 -0
  7. package/docs/skills.md +45 -0
  8. package/lib/manager.js +459 -0
  9. package/package.json +61 -0
  10. package/server/db/database.js +239 -0
  11. package/server/index.js +442 -0
  12. package/server/middleware/auth.js +35 -0
  13. package/server/public/app.html +559 -0
  14. package/server/public/css/app.css +608 -0
  15. package/server/public/css/styles.css +472 -0
  16. package/server/public/favicon.svg +17 -0
  17. package/server/public/js/app.js +3283 -0
  18. package/server/public/login.html +313 -0
  19. package/server/routes/agents.js +125 -0
  20. package/server/routes/auth.js +105 -0
  21. package/server/routes/browser.js +116 -0
  22. package/server/routes/mcp.js +164 -0
  23. package/server/routes/memory.js +193 -0
  24. package/server/routes/messaging.js +153 -0
  25. package/server/routes/protocols.js +87 -0
  26. package/server/routes/scheduler.js +63 -0
  27. package/server/routes/settings.js +98 -0
  28. package/server/routes/skills.js +107 -0
  29. package/server/routes/store.js +1192 -0
  30. package/server/services/ai/compaction.js +82 -0
  31. package/server/services/ai/engine.js +1690 -0
  32. package/server/services/ai/models.js +46 -0
  33. package/server/services/ai/multiStep.js +112 -0
  34. package/server/services/ai/providers/anthropic.js +181 -0
  35. package/server/services/ai/providers/base.js +40 -0
  36. package/server/services/ai/providers/google.js +187 -0
  37. package/server/services/ai/providers/grok.js +121 -0
  38. package/server/services/ai/providers/ollama.js +162 -0
  39. package/server/services/ai/providers/openai.js +167 -0
  40. package/server/services/ai/toolRunner.js +218 -0
  41. package/server/services/browser/controller.js +320 -0
  42. package/server/services/cli/executor.js +204 -0
  43. package/server/services/mcp/client.js +260 -0
  44. package/server/services/memory/embeddings.js +126 -0
  45. package/server/services/memory/manager.js +431 -0
  46. package/server/services/messaging/base.js +23 -0
  47. package/server/services/messaging/discord.js +238 -0
  48. package/server/services/messaging/manager.js +328 -0
  49. package/server/services/messaging/telegram.js +243 -0
  50. package/server/services/messaging/telnyx.js +693 -0
  51. package/server/services/messaging/whatsapp.js +304 -0
  52. package/server/services/scheduler/cron.js +312 -0
  53. package/server/services/websocket.js +191 -0
  54. package/server/utils/security.js +71 -0
@@ -0,0 +1,442 @@
1
+ require('dotenv').config();
2
+
3
+ const express = require('express');
4
+ const session = require('express-session');
5
+ const SQLiteStore = require('connect-sqlite3')(session);
6
+ const { createServer } = require('http');
7
+ const { Server: SocketIO } = require('socket.io');
8
+ const helmet = require('helmet');
9
+ const cors = require('cors');
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+
13
+ const db = require('./db/database');
14
+ const { requireAuth, requireNoAuth } = require('./middleware/auth');
15
+ const { sanitizeError, detectPromptInjection } = require('./utils/security');
16
+
17
+ const app = express();
18
+ const httpServer = createServer(app);
19
+ const io = new SocketIO(httpServer, {
20
+ cors: {
21
+ origin: process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : false,
22
+ credentials: true
23
+ }
24
+ });
25
+
26
+ // ── Console Log Interceptor ──
27
+ const logHistory = [];
28
+ const MAX_LOG_HISTORY = 200;
29
+
30
+ function broadcastLog(type, args) {
31
+ const msg = Array.from(args).map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
32
+ const logEntry = { type, message: msg, timestamp: new Date().toISOString() };
33
+ logHistory.push(logEntry);
34
+ if (logHistory.length > MAX_LOG_HISTORY) logHistory.shift();
35
+ // Broadcast only to authenticated user rooms, never to unauthenticated sockets
36
+ for (const [, socket] of io.sockets.sockets) {
37
+ const uid = socket.request?.session?.userId;
38
+ if (uid) socket.emit('server:log', logEntry);
39
+ }
40
+ }
41
+
42
+ const originalConsole = {
43
+ log: console.log,
44
+ error: console.error,
45
+ warn: console.warn,
46
+ info: console.info
47
+ };
48
+
49
+ console.log = function (...args) { originalConsole.log.apply(console, args); broadcastLog('log', args); };
50
+ console.error = function (...args) { originalConsole.error.apply(console, args); broadcastLog('error', args); };
51
+ console.warn = function (...args) { originalConsole.warn.apply(console, args); broadcastLog('warn', args); };
52
+ console.info = function (...args) { originalConsole.info.apply(console, args); broadcastLog('info', args); };
53
+
54
+ io.on('connection', (socket) => {
55
+ socket.on('client:request_logs', () => {
56
+ // Only serve log history to authenticated sockets
57
+ if (!socket.request?.session?.userId) return;
58
+ socket.emit('server:log_history', logHistory);
59
+ });
60
+ });
61
+
62
+ if (!process.env.SESSION_SECRET) {
63
+ console.warn('WARNING: SESSION_SECRET not set — using insecure default. Set it in .env before exposing this server.');
64
+ }
65
+
66
+ const PORT = process.env.PORT || 3060;
67
+ const DATA_DIR = path.join(__dirname, '../data');
68
+ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
69
+
70
+ // ── Middleware ──
71
+
72
+ // When running behind a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel…)
73
+ // set SECURE_COOKIES=true in .env so the session cookie is marked Secure and
74
+ // express trusts the X-Forwarded-* headers from the proxy.
75
+ const SECURE_COOKIES = process.env.SECURE_COOKIES === 'true';
76
+ if (SECURE_COOKIES) {
77
+ app.set('trust proxy', 1); // trust first hop (reverse proxy)
78
+ }
79
+
80
+ // WebSocket CSP source: ws+wss on plain HTTP, wss-only on HTTPS
81
+ const wsConnectSrc = SECURE_COOKIES ? ['wss:'] : ['ws:', 'wss:'];
82
+
83
+ app.use(helmet({
84
+ // Disable headers that only make sense on HTTPS — this app runs on plain HTTP (Tailscale/localhost).
85
+ strictTransportSecurity: false, // HSTS: would force browser to upgrade HTTP→HTTPS permanently
86
+ crossOriginOpenerPolicy: false, // COOP: ignored on non-HTTPS origins, causes browser warning
87
+ originAgentCluster: false, // OAC: causes "previously placed in site-keyed cluster" warning on HTTP
88
+ contentSecurityPolicy: {
89
+ directives: {
90
+ defaultSrc: ["'self'"],
91
+ scriptSrc: ["'self'", "'unsafe-inline'"],
92
+ scriptSrcAttr: ["'unsafe-inline'"],
93
+ styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
94
+ imgSrc: ["'self'", "data:", "blob:", "https://api.qrserver.com"],
95
+ connectSrc: ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com", ...wsConnectSrc],
96
+ fontSrc: ["'self'", "data:", "https://fonts.gstatic.com"],
97
+ formAction: ["'self'"], // explicit: prevents form-submission to external origins
98
+ frameAncestors: ["'self'"], // explicit: prevents iframe embedding from other origins
99
+ // Disable upgrade-insecure-requests — helmet adds this by default in v7+,
100
+ // which causes browsers to upgrade HTTP subresource requests to HTTPS,
101
+ // breaking plain-HTTP deployments (Tailscale, localhost).
102
+ upgradeInsecureRequests: null
103
+ }
104
+ }
105
+ }));
106
+ app.use(cors({
107
+ origin: process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : false,
108
+ credentials: true
109
+ }));
110
+ app.use(express.json({ limit: '10mb' }));
111
+ app.use(express.urlencoded({ extended: true }));
112
+
113
+ const sessionMiddleware = session({
114
+ store: new SQLiteStore({ db: 'sessions.db', dir: DATA_DIR }),
115
+ secret: process.env.SESSION_SECRET || 'neoagent-dev-secret-change-me',
116
+ name: 'neoagent.sid',
117
+ resave: false,
118
+ saveUninitialized: false,
119
+ cookie: {
120
+ maxAge: 7 * 24 * 60 * 60 * 1000,
121
+ httpOnly: true,
122
+ sameSite: 'lax',
123
+ secure: SECURE_COOKIES // true when behind HTTPS proxy; false for plain HTTP (Tailscale/localhost)
124
+ }
125
+ });
126
+
127
+ app.use(sessionMiddleware);
128
+
129
+ // Share session with Socket.IO
130
+ io.use((socket, next) => {
131
+ sessionMiddleware(socket.request, {}, next);
132
+ });
133
+
134
+ // ── Routes ──
135
+
136
+ app.use(require('./routes/auth'));
137
+ app.use('/api/settings', require('./routes/settings'));
138
+ app.use('/api/agents', require('./routes/agents'));
139
+ app.use('/api/messaging', require('./routes/messaging'));
140
+ app.use('/api/mcp', require('./routes/mcp'));
141
+ app.use('/api/skills', require('./routes/skills'));
142
+ app.use('/api/protocols', require('./routes/protocols'));
143
+ app.use('/api/store', require('./routes/store'));
144
+ app.use('/api/memory', require('./routes/memory'));
145
+ app.use('/api/scheduler', require('./routes/scheduler'));
146
+ app.use('/api/browser', require('./routes/browser'));
147
+
148
+ // ── Telnyx voice webhook ──
149
+ // Protected by a shared-secret token in the query string.
150
+ // Set TELNYX_WEBHOOK_TOKEN in .env and append ?token=<value> to the webhook URL
151
+ // you configure in the Telnyx portal / NeoAgent connect modal.
152
+ app.post('/api/telnyx/webhook', (req, res, next) => {
153
+ const expected = process.env.TELNYX_WEBHOOK_TOKEN;
154
+ if (expected) {
155
+ const provided = req.query.token || '';
156
+ // Use timing-safe comparison to prevent token brute-forcing via timing oracles
157
+ const crypto = require('crypto');
158
+ const a = Buffer.from(provided.padEnd(expected.length));
159
+ const b = Buffer.from(expected);
160
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
161
+ console.warn('[Telnyx webhook] Rejected request with invalid or missing token');
162
+ return res.status(403).send('Forbidden');
163
+ }
164
+ }
165
+ next();
166
+ }, async (req, res) => {
167
+ res.status(200).send('OK'); // Acknowledge immediately
168
+ const manager = app.locals.messagingManager;
169
+ if (manager) await manager.handleTelnyxWebhook(req.body).catch(err => console.error('[Telnyx webhook]', err.message));
170
+ });
171
+
172
+ // Telnyx-generated audio files must be publicly accessible so Telnyx's servers
173
+ // can fetch them via the webhook callback URL. Directory listing is disabled and
174
+ // non-audio files are rejected to minimize accidental exposure.
175
+ app.use('/telnyx-audio', express.static(path.join(DATA_DIR, 'telnyx-audio'), {
176
+ index: false,
177
+ setHeaders: (res, filePath) => {
178
+ if (!filePath.match(/\.(mp3|wav|ogg|aac|m4a)$/i)) {
179
+ res.status(403).end();
180
+ }
181
+ }
182
+ }));
183
+
184
+ app.use('/screenshots', requireAuth, express.static(path.join(DATA_DIR, 'screenshots')));
185
+
186
+ // ── Pages ──
187
+
188
+ app.get('/login', requireNoAuth, (req, res) => {
189
+ res.sendFile(path.join(__dirname, 'public', 'login.html'));
190
+ });
191
+
192
+ app.get('/app', requireAuth, (req, res) => {
193
+ res.sendFile(path.join(__dirname, 'public', 'app.html'));
194
+ });
195
+
196
+ app.get('/app/*', requireAuth, (req, res) => {
197
+ res.sendFile(path.join(__dirname, 'public', 'app.html'));
198
+ });
199
+
200
+ // Serve app.html and app.js explicitly behind auth so they can't be fetched
201
+ // directly via the static middleware without a valid session.
202
+ app.get('/app.html', requireAuth, (req, res) => {
203
+ res.sendFile(path.join(__dirname, 'public', 'app.html'));
204
+ });
205
+ app.get('/js/app.js', requireAuth, (req, res) => {
206
+ res.sendFile(path.join(__dirname, 'public', 'js', 'app.js'));
207
+ });
208
+
209
+ app.use(express.static(path.join(__dirname, 'public')));
210
+
211
+ app.get('/', (req, res) => {
212
+ if (req.session && req.session.userId) return res.redirect('/app');
213
+ res.redirect('/login');
214
+ });
215
+
216
+ app.get('/api/health', requireAuth, (req, res) => {
217
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
218
+ });
219
+
220
+ // ── Service Initialization ──
221
+
222
+ const startServices = async () => {
223
+ try {
224
+ const { MemoryManager } = require('./services/memory/manager');
225
+ const memoryManager = new MemoryManager();
226
+ app.locals.memoryManager = memoryManager;
227
+
228
+ const { MCPClient } = require('./services/mcp/client');
229
+ const mcpClient = new MCPClient();
230
+ app.locals.mcpClient = mcpClient;
231
+
232
+ const { BrowserController } = require('./services/browser/controller');
233
+ const browserController = new BrowserController();
234
+ // Restore saved headless preference for the first user (single-user app)
235
+ const headlessSetting = db.prepare('SELECT value FROM user_settings WHERE key = ? ORDER BY user_id LIMIT 1').get('headless_browser');
236
+ if (headlessSetting) {
237
+ const val = headlessSetting.value;
238
+ browserController.headless = val !== 'false' && val !== false && val !== '0';
239
+ }
240
+ app.locals.browserController = browserController;
241
+
242
+ const { AgentEngine } = require('./services/ai/engine');
243
+ const agentEngine = new AgentEngine(io, { memoryManager, mcpClient, browserController, messagingManager: null /* set below */ });
244
+ app.locals.agentEngine = agentEngine;
245
+
246
+ const { MultiStepOrchestrator } = require('./services/ai/multiStep');
247
+ const multiStep = new MultiStepOrchestrator(agentEngine, io);
248
+ app.locals.multiStep = multiStep;
249
+
250
+ const { MessagingManager } = require('./services/messaging/manager');
251
+ const messagingManager = new MessagingManager(io);
252
+ app.locals.messagingManager = messagingManager;
253
+ // Inject messagingManager into the already-created agentEngine
254
+ agentEngine.messagingManager = messagingManager;
255
+
256
+ messagingManager.restoreConnections().catch(err => console.error('[Messaging] Restore error:', err.message));
257
+
258
+ const users = db.prepare('SELECT id FROM users').all();
259
+ for (const u of users) {
260
+ mcpClient.loadFromDB(u.id).catch(err => console.error('[MCP] Auto-start error:', err.message));
261
+ }
262
+
263
+ // Per-user message queues: batch & combine messages while AI is busy
264
+ const userQueues = {};
265
+ app.locals.userQueues = userQueues;
266
+
267
+ async function processMessage(userId, msg) {
268
+ if (!userQueues[userId]) userQueues[userId] = { running: false, pending: [] };
269
+ const q = userQueues[userId];
270
+
271
+ if (q.running) {
272
+ const last = q.pending[q.pending.length - 1];
273
+ if (last && last.platform === msg.platform && last.chatId === msg.chatId) {
274
+ last.content += '\n' + msg.content;
275
+ last.messageId = msg.messageId;
276
+ } else {
277
+ q.pending.push({ ...msg });
278
+ }
279
+ return;
280
+ }
281
+
282
+ q.running = true;
283
+ try {
284
+ await messagingManager.markRead(userId, msg.platform, msg.chatId, msg.messageId).catch(() => { });
285
+ await messagingManager.sendTyping(userId, msg.platform, msg.chatId, true).catch(() => { });
286
+ const mediaNote = msg.localMediaPath
287
+ ? `\nMedia attached at: ${msg.localMediaPath} (type: ${msg.mediaType}). You can reference or forward it with send_message media_path.`
288
+ : '';
289
+ // Detect and log prompt injection attempts from external sources
290
+ if (detectPromptInjection(msg.content)) {
291
+ console.warn(`[Security] Possible prompt injection attempt from ${msg.sender} on ${msg.platform}: ${msg.content.slice(0, 200)}`);
292
+ }
293
+ // Wrap external content in delimiters — prevents prompt injection from untrusted senders
294
+ const isVoiceCall = msg.platform === 'telnyx' && msg.mediaType === 'voice';
295
+ const isVoiceNote = !isVoiceCall && msg.mediaType === 'audio'; // e.g. WhatsApp voice notes transcribed via STT
296
+ const isDiscordGuild = msg.platform === 'discord' && msg.isGroup;
297
+
298
+ // Channel context block for Discord guild/channel messages
299
+ const discordContext = (isDiscordGuild && Array.isArray(msg.channelContext) && msg.channelContext.length)
300
+ ? '\n\nRecent channel context (oldest → newest):\n' +
301
+ msg.channelContext.map(m => `[${m.author}]: ${m.content}`).join('\n')
302
+ : '';
303
+
304
+ const sttNote = isVoiceNote
305
+ ? '\n[Note: This message was sent as a voice note and transcribed via speech-to-text. The transcription may not be perfectly accurate — words may be misheard, punctuation added automatically, and phrasing may differ from what was intended.]'
306
+ : '';
307
+
308
+ const prompt = isVoiceCall
309
+ ? `You are on a live phone call. The caller (${msg.senderName || msg.sender}) said (transcribed via speech-to-text — may not be perfectly accurate):
310
+ <caller_speech>
311
+ ${msg.content}
312
+ </caller_speech>
313
+
314
+ Respond via send_message with platform="telnyx" and to="${msg.chatId}".
315
+ Rules for voice responses:
316
+ - Call send_message EXACTLY ONCE with your complete reply. Do NOT call it multiple times.
317
+ - Keep it brief and conversational — this will be spoken aloud via TTS.
318
+ - NO markdown, bullet points, bold, headers, or special formatting.
319
+ - Speak naturally. Never say things like "How can I assist you further?" — just stop when done.
320
+ - Always respond; never use [NO RESPONSE] on a live call.`
321
+ : `You received a ${msg.platform} message from ${msg.senderName || msg.sender} (chat: ${msg.chatId}):\n<external_message>\n${msg.content}\n</external_message>${mediaNote}${discordContext}${sttNote}
322
+
323
+ Reply to this message using send_message with platform="${msg.platform}" and to="${msg.chatId}".
324
+ Text like a person: split across messages naturally when it fits the content. Never end with "anything else?" or close-out phrases — just stop when you're done.
325
+ You can also send images/files by setting media_path to a local file path.
326
+ If no reply is needed (e.g. the message is just an acknowledgement like "ok", "thanks", or you already said everything), call send_message with content "[NO RESPONSE]" to explicitly stay silent.`;
327
+ // Get or create a persistent conversation keyed by platform + chat ID.
328
+ // The engine's conversationId path handles history, time-gap markers, and compaction natively.
329
+ let convRow = db.prepare(
330
+ 'SELECT id FROM conversations WHERE user_id = ? AND platform = ? AND platform_chat_id = ?'
331
+ ).get(userId, msg.platform, msg.chatId);
332
+ if (!convRow) {
333
+ const convId = require('crypto').randomUUID();
334
+ db.prepare(
335
+ 'INSERT INTO conversations (id, user_id, platform, platform_chat_id, title) VALUES (?, ?, ?, ?, ?)'
336
+ ).run(convId, userId, msg.platform, msg.chatId, `${msg.platform} — ${msg.senderName || msg.sender || msg.chatId}`);
337
+ convRow = { id: convId };
338
+ }
339
+ const runOpts = { triggerSource: 'messaging', conversationId: convRow.id, source: msg.platform, chatId: msg.chatId, context: { rawUserMessage: msg.content } };
340
+ if (msg.localMediaPath) runOpts.mediaAttachments = [{ path: msg.localMediaPath, type: msg.mediaType }];
341
+ await agentEngine.run(userId, prompt, runOpts);
342
+ } finally {
343
+ await messagingManager.sendTyping(userId, msg.platform, msg.chatId, false).catch(() => { });
344
+ q.running = false;
345
+ if (q.pending.length > 0) {
346
+ const next = q.pending.shift();
347
+ processMessage(userId, next);
348
+ }
349
+ }
350
+ }
351
+
352
+ // Wire messaging → agent: incoming messages trigger agent
353
+ messagingManager.registerHandler(async (userId, msg) => {
354
+ // Discord and Telegram handle their own access control internally
355
+ if (msg.platform !== 'discord' && msg.platform !== 'telegram') {
356
+ // Whitelist check: if user has set a whitelist for this platform, block unknown senders
357
+ const whitelistRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
358
+ .get(userId, `platform_whitelist_${msg.platform}`);
359
+ if (whitelistRow) {
360
+ try {
361
+ const whitelist = JSON.parse(whitelistRow.value);
362
+ if (Array.isArray(whitelist) && whitelist.length > 0) {
363
+ const normalize = (id) => {
364
+ const digits = (id || '').replace(/[^0-9]/g, '');
365
+ return digits.length > 10 ? digits.slice(-10) : digits;
366
+ };
367
+ const senderNorm = normalize(msg.sender || msg.chatId);
368
+ const allowed = whitelist.some(n => normalize(n) === senderNorm);
369
+ if (!allowed) {
370
+ console.log(`[Messaging] Blocked ${msg.platform} message from ${msg.sender} (not in whitelist)`);
371
+ io.to(`user:${userId}`).emit('messaging:blocked_sender', {
372
+ platform: msg.platform,
373
+ sender: msg.sender,
374
+ chatId: msg.chatId,
375
+ senderName: msg.senderName || null
376
+ });
377
+ return;
378
+ }
379
+ }
380
+ } catch { /* malformed whitelist, allow through */ }
381
+ }
382
+ }
383
+
384
+ const upsertSetting = db.prepare('INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
385
+ upsertSetting.run(userId, 'last_platform', msg.platform);
386
+ upsertSetting.run(userId, 'last_chat_id', msg.chatId);
387
+
388
+ await processMessage(userId, msg);
389
+ });
390
+
391
+ const { Scheduler } = require('./services/scheduler/cron');
392
+ const scheduler = new Scheduler(io, agentEngine);
393
+ app.locals.scheduler = scheduler;
394
+ agentEngine.scheduler = scheduler;
395
+ scheduler.start();
396
+
397
+ const { setupWebSocket } = require('./services/websocket');
398
+ setupWebSocket(io, { agentEngine, messagingManager, mcpClient, scheduler, memoryManager, app });
399
+
400
+ app.locals.io = io;
401
+
402
+ console.log('All services initialized');
403
+ } catch (err) {
404
+ console.error('Service init error:', err);
405
+ }
406
+ };
407
+
408
+ // ── Global Error Handler ──
409
+
410
+ // Must be registered after all routes. Sanitizes error details so internal paths
411
+ // and stack traces are never exposed in API responses.
412
+ app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
413
+ console.error('[Unhandled error]', err);
414
+ const status = err.status || err.statusCode || 500;
415
+ const message = sanitizeError(err);
416
+ if (req.path.startsWith('/api/')) {
417
+ return res.status(status).json({ error: message });
418
+ }
419
+ res.status(status).send('Something went wrong.');
420
+ });
421
+
422
+ httpServer.listen(PORT, async () => {
423
+ console.log(`NeoAgent running on http://localhost:${PORT}`);
424
+ await startServices();
425
+ });
426
+
427
+ // ── Graceful Shutdown ──
428
+
429
+ process.on('SIGINT', async () => {
430
+ console.log('Shutting down...');
431
+ if (app.locals.scheduler) app.locals.scheduler.stop();
432
+ if (app.locals.mcpClient) await app.locals.mcpClient.shutdown().catch(() => { });
433
+ if (app.locals.browserController) await app.locals.browserController.closeBrowser().catch(() => { });
434
+ db.close();
435
+ process.exit(0);
436
+ });
437
+
438
+ process.on('SIGTERM', () => {
439
+ process.emit('SIGINT');
440
+ });
441
+
442
+ module.exports = { app, io, httpServer };
@@ -0,0 +1,35 @@
1
+ function requireAuth(req, res, next) {
2
+ if (!req.session || !req.session.userId) {
3
+ if (req.path.startsWith('/api/')) {
4
+ return res.status(401).json({ error: 'Unauthorized' });
5
+ }
6
+ return res.redirect('/login');
7
+ }
8
+ next();
9
+ }
10
+
11
+ function requireNoAuth(req, res, next) {
12
+ if (req.session && req.session.userId) {
13
+ return res.redirect('/app');
14
+ }
15
+ next();
16
+ }
17
+
18
+ function attachUser(req, res, next) {
19
+ if (req.session && req.session.userId) {
20
+ const db = require('../db/database');
21
+ const user = db.prepare('SELECT id, username, email, created_at FROM users WHERE id = ?').get(req.session.userId);
22
+ if (user) {
23
+ req.user = user;
24
+ } else {
25
+ req.session.destroy(() => {});
26
+ if (req.path.startsWith('/api/')) {
27
+ return res.status(401).json({ error: 'Session invalid' });
28
+ }
29
+ return res.redirect('/login');
30
+ }
31
+ }
32
+ next();
33
+ }
34
+
35
+ module.exports = { requireAuth, requireNoAuth, attachUser };