neuroalpha 0.3.16 → 0.5.1

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 (41) hide show
  1. package/README.md +0 -1
  2. package/config/certs/russian_trusted_root_ca.pem +33 -0
  3. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  4. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -6
  5. package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +2 -2
  6. package/package.json +60 -53
  7. package/public/activate.html +36 -0
  8. package/public/activate.js +33 -0
  9. package/public/admin.html +126 -7
  10. package/public/app.js +608 -182
  11. package/public/index.html +57 -47
  12. package/public/landing.html +106 -37
  13. package/public/legal.html +4 -4
  14. package/public/styles.css +152 -36
  15. package/server/api/routes.js +308 -37
  16. package/server/auth/index.js +79 -15
  17. package/server/brokers/index.js +1022 -172
  18. package/server/build-info.js +7 -0
  19. package/server/config.js +13 -6
  20. package/server/data/moex.js +6 -4
  21. package/server/db.js +22 -18
  22. package/server/desktop-entry.js +103 -0
  23. package/server/hub.js +28 -14
  24. package/server/index.js +82 -12
  25. package/server/instance/agent.js +259 -0
  26. package/server/instance/central.js +491 -0
  27. package/server/instance/identity.js +79 -0
  28. package/server/instance/provision.js +166 -0
  29. package/server/instance/signing.js +71 -0
  30. package/server/model/edge.js +19 -0
  31. package/server/model/forecast.js +107 -57
  32. package/server/model/live.js +1 -1
  33. package/server/model/models.js +12 -3
  34. package/server/model/research.js +218 -30
  35. package/server/notify/index.js +51 -16
  36. package/server/params-meta.js +3 -3
  37. package/server/role.js +44 -0
  38. package/server/security.js +40 -0
  39. package/server/strategist/index.js +462 -488
  40. package/server/strategist/tools.js +675 -532
  41. package/server/tls-ca.js +32 -0
@@ -0,0 +1,7 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Параметры, вшитые в защищённую сборку для компьютера клиента (scripts/build-desktop.mjs подставляет __NA_BUILD__).
3
+ // В собранной программе режим, адрес центра и открытый ключ центра НЕ берутся из .env и переменных окружения —
4
+ // иначе можно было бы запустить её в режиме разработчика или подписать лицензию своим ключом.
5
+ /* global __NA_BUILD__ */
6
+ export const BUILD = typeof __NA_BUILD__ !== 'undefined' && __NA_BUILD__ ? __NA_BUILD__ : null;
7
+ export const IS_PACKAGED = Boolean(BUILD);
package/server/config.js CHANGED
@@ -3,15 +3,22 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
- export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
+ import { IS_PACKAGED, BUILD } from './build-info.js';
7
+
8
+ // В защищённой сборке файлы интерфейса распаковываются во временный каталог (NEUROALPHA_ROOT задаёт загрузчик)
9
+ export const ROOT = IS_PACKAGED
10
+ ? path.resolve(process.env.NEUROALPHA_ROOT || process.cwd())
11
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
12
  export const DATA_DIR = process.env.NEUROALPHA_DATA_DIR
8
13
  ? path.resolve(process.env.NEUROALPHA_DATA_DIR)
9
14
  : path.join(ROOT, 'data');
10
15
 
11
- const envFile = path.join(ROOT, '.env');
12
- if (fs.existsSync(envFile)) process.loadEnvFile(envFile);
13
- const cwdEnv = path.resolve(process.cwd(), '.env');
14
- if (cwdEnv !== envFile && fs.existsSync(cwdEnv)) process.loadEnvFile(cwdEnv);
16
+ if (!IS_PACKAGED) {
17
+ const envFile = path.join(ROOT, '.env');
18
+ if (fs.existsSync(envFile)) process.loadEnvFile(envFile);
19
+ const cwdEnv = path.resolve(process.cwd(), '.env');
20
+ if (cwdEnv !== envFile && fs.existsSync(cwdEnv)) process.loadEnvFile(cwdEnv);
21
+ }
15
22
 
16
23
  export const strategyDefaults = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/strategy.json'), 'utf8'));
17
24
 
@@ -43,4 +50,4 @@ export const llmConfig = {
43
50
  llmConfig.enabled = llmConfig.provider !== 'none' && !!llmConfig.baseUrl && !!llmConfig.model && !!llmConfig.apiKey;
44
51
 
45
52
  export const PORT = Number(process.env.PORT || 3000);
46
- export const DEFAULT_CLOUD_URL = (process.env.CLOUD_URL || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
53
+ export const DEFAULT_CLOUD_URL = ((IS_PACKAGED ? BUILD.centralUrl : process.env.CLOUD_URL) || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
@@ -120,16 +120,18 @@ export async function ensureCandles(symbol, interval, fromTs, tillTs, { signal,
120
120
  const mskDow = mskDate.getUTCDay();
121
121
  const mskHour = mskDate.getUTCHours();
122
122
  const isTradingHours = mskDow >= 1 && mskDow <= 5 && mskHour >= 9 && mskHour < 24;
123
- const checkCooldownMs = isTradingHours ? 30 * 60000 : 4 * 3600000;
124
- const dbIsAlreadyFresh = (now - range.max < 45 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
123
+ const checkCooldownMs = isTradingHours ? 8 * 60000 : 4 * 3600000;
124
+ const dbIsAlreadyFresh = (now - range.max < 15 * 60000) || (!isTradingHours && now - range.max < 18 * 3600000);
125
125
 
126
126
  if (!dbIsAlreadyFresh && range.max < Math.min(tillTs, now) - barMs(interval) && (now - lastCheck >= checkCooldownMs)) {
127
+ // Записываем время проверки хвоста ВСЕГДА (даже если новых свечей у бумаги на бирже ещё не появилось),
128
+ // чтобы не опрашивать MOEX ISS по всем 100 бумагам каждые 2 минуты!
129
+ lastTailCheckAt.set(tailKey, now);
130
+ kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
127
131
  await fetchCandles(symbol, interval, tsToDate(range.max), tillTs < now ? tsToDate(tillTs) : null, {
128
132
  signal,
129
133
  onBatch: (page) => saveCandles(symbol, interval, page),
130
134
  });
131
- lastTailCheckAt.set(tailKey, now);
132
- kvSet(pKey, { v: 2, fromTs: Math.min(fromTs, probe?.v === 2 ? probe.fromTs : range.min), tailCheckedAt: now });
133
135
  }
134
136
 
135
137
  return loadCandles(symbol, interval, fromTs, tillTs);
package/server/db.js CHANGED
@@ -6,6 +6,7 @@ import { ROOT, DATA_DIR } from './config.js';
6
6
 
7
7
  let getUid = () => 1;
8
8
  export function registerUidResolver(fn) { getUid = fn; }
9
+ export const currentUid = () => getUid();
9
10
 
10
11
  const dir = DATA_DIR;
11
12
  fs.mkdirSync(dir, { recursive: true });
@@ -197,10 +198,13 @@ for (const [tbl, col, def] of [
197
198
  }
198
199
 
199
200
  // Гарантируем наличие локального пользователя по умолчанию (id = 1, режим «Хозяин»)
201
+ const randomNotifyCode = () => `NA-${Array.from(crypto.getRandomValues(new Uint8Array(3)), (b) => b.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
200
202
  if (!db.prepare('SELECT id FROM users WHERE id = 1').get()) {
201
203
  db.prepare(`INSERT INTO users (id, email, name, pass_hash, role, plan, plan_until, notify_code, created_at)
202
- VALUES (1, 'owner@neuroalpha.local', 'Хозяин', '', 'admin', 'owner', 0, 'NA-OWNER1', ?)`).run(Date.now());
204
+ VALUES (1, 'owner@neuroalpha.local', 'Хозяин', '', 'admin', 'owner', 0, ?, ?)`).run(randomNotifyCode(), Date.now());
203
205
  } else {
206
+ // Общеизвестный код привязки владельца заменяется на случайный (привязанные ранее чаты сохраняются)
207
+ db.prepare("UPDATE users SET notify_code = ? WHERE id = 1 AND (notify_code IS NULL OR notify_code = '' OR UPPER(notify_code) = 'NA-OWNER1')").run(randomNotifyCode());
204
208
  db.prepare(`UPDATE users SET plan = 'owner' WHERE id = 1 AND role = 'admin' AND plan IN ('free', 'pro')`).run();
205
209
  db.prepare("UPDATE users SET name = 'Пользователь' WHERE id = 1 AND name = 'Владелец платформы'").run();
206
210
  }
@@ -226,22 +230,22 @@ export function kvSet(key, value) {
226
230
  db.prepare('INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)').run(key, JSON.stringify(value));
227
231
  }
228
232
 
233
+ // uid = 1 — исторические ключи владельца без префикса; uid = 0 (аноним) — отдельное пустое пространство
234
+ const userKey = (key, uid) => (Number(uid) === 1 ? key : `u:${Number(uid) || 0}:${key}`);
229
235
  export function userKvGet(key, def = null, uid = getUid()) {
230
- const k = (!uid || uid === 1) ? key : `u:${uid}:${key}`;
231
- return kvGet(k, def);
236
+ return kvGet(userKey(key, uid), def);
232
237
  }
233
238
  export function userKvSet(key, value, uid = getUid()) {
234
- const k = (!uid || uid === 1) ? key : `u:${uid}:${key}`;
235
- kvSet(k, value);
239
+ kvSet(userKey(key, uid), value);
236
240
  }
237
241
 
238
242
  // ---------- чат ----------
239
243
  export function currentConversation(uid = getUid()) {
240
- const r = db.prepare('SELECT * FROM conversations WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT 1').get(uid || 1);
244
+ const r = db.prepare('SELECT * FROM conversations WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT 1').get(uid ?? 1);
241
245
  return r || newConversation(uid);
242
246
  }
243
247
  export function newConversation(uid = getUid()) {
244
- const id = Number(db.prepare('INSERT INTO conversations (created_at, user_id) VALUES (?, ?)').run(Date.now(), uid || 1).lastInsertRowid);
248
+ const id = Number(db.prepare('INSERT INTO conversations (created_at, user_id) VALUES (?, ?)').run(Date.now(), uid ?? 1).lastInsertRowid);
245
249
  return db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
246
250
  }
247
251
  const parseMsg = (m) => m && ({
@@ -273,27 +277,27 @@ export function getMessages(convId, limit = 400) {
273
277
  // ---------- журнал гипотез ----------
274
278
  export function addJournal(e, uid = getUid()) {
275
279
  const r = db.prepare('INSERT INTO journal (ts, hypothesis, change, run_ids, result, verdict, source, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
276
- .run(Date.now(), e.hypothesis || '', e.change || '', JSON.stringify(e.run_ids || []), e.result || '', e.verdict || 'unclear', e.source || 'ai', uid || 1);
280
+ .run(Date.now(), e.hypothesis || '', e.change || '', JSON.stringify(e.run_ids || []), e.result || '', e.verdict || 'unclear', e.source || 'ai', uid ?? 1);
277
281
  return Number(r.lastInsertRowid);
278
282
  }
279
283
  export function listJournal(limit = 200, uid = getUid()) {
280
- return db.prepare('SELECT * FROM journal WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid || 1, limit).map((j) => ({ ...j, run_ids: JSON.parse(j.run_ids || '[]') }));
284
+ return db.prepare('SELECT * FROM journal WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid ?? 1, limit).map((j) => ({ ...j, run_ids: JSON.parse(j.run_ids || '[]') }));
281
285
  }
282
286
  export function deleteJournal(id, uid = getUid()) {
283
- db.prepare('DELETE FROM journal WHERE id = ? AND COALESCE(user_id, 1) = ?').run(id, uid || 1);
287
+ db.prepare('DELETE FROM journal WHERE id = ? AND COALESCE(user_id, 1) = ?').run(id, uid ?? 1);
284
288
  }
285
289
 
286
290
  // ---------- история настроек ----------
287
291
  const parseHistory = (h) => h && ({ ...h, changes: JSON.parse(h.changes || '{}') });
288
292
  export function addSettingsHistory(source, reason, changes, uid = getUid()) {
289
293
  return Number(db.prepare('INSERT INTO settings_history (ts, source, reason, changes, user_id) VALUES (?, ?, ?, ?, ?)')
290
- .run(Date.now(), source, reason || '', JSON.stringify(changes), uid || 1).lastInsertRowid);
294
+ .run(Date.now(), source, reason || '', JSON.stringify(changes), uid ?? 1).lastInsertRowid);
291
295
  }
292
296
  export function listSettingsHistory(limit = 50, uid = getUid()) {
293
- return db.prepare('SELECT * FROM settings_history WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid || 1, limit).map(parseHistory);
297
+ return db.prepare('SELECT * FROM settings_history WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC LIMIT ?').all(uid ?? 1, limit).map(parseHistory);
294
298
  }
295
299
  export function getSettingsHistory(id, uid = getUid()) {
296
- return parseHistory(db.prepare('SELECT * FROM settings_history WHERE id = ? AND COALESCE(user_id, 1) = ?').get(id, uid || 1));
300
+ return parseHistory(db.prepare('SELECT * FROM settings_history WHERE id = ? AND COALESCE(user_id, 1) = ?').get(id, uid ?? 1));
297
301
  }
298
302
 
299
303
  const insertCandle = db.prepare(`INSERT OR REPLACE INTO candles
@@ -321,7 +325,7 @@ export function candleRange(symbol, interval) {
321
325
  }
322
326
 
323
327
  export function createRun(mode, params, uid = getUid()) {
324
- const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid || 1);
328
+ const r = db.prepare('INSERT INTO runs (mode, status, created_at, params, user_id) VALUES (?, ?, ?, ?, ?)').run(mode, 'running', Date.now(), JSON.stringify(params), uid ?? 1);
325
329
  return Number(r.lastInsertRowid);
326
330
  }
327
331
 
@@ -360,10 +364,10 @@ const parseRun = (r) => r && ({
360
364
  });
361
365
 
362
366
  export function listRuns(uid = getUid()) {
363
- return db.prepare('SELECT id, mode, status, created_at, finished_at, params, metrics, error, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? OR showcase = 1 ORDER BY id DESC LIMIT 100').all(uid || 1).map(parseRun);
367
+ return db.prepare('SELECT id, mode, status, created_at, finished_at, params, metrics, error, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? OR showcase = 1 ORDER BY id DESC LIMIT 100').all(uid ?? 1).map(parseRun);
364
368
  }
365
369
  export function allRuns(uid = getUid()) {
366
- return db.prepare('SELECT id, mode, status, params, metrics, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC').all(uid || 1).map(parseRun);
370
+ return db.prepare('SELECT id, mode, status, params, metrics, user_id, showcase FROM runs WHERE COALESCE(user_id, 1) = ? ORDER BY id DESC').all(uid ?? 1).map(parseRun);
367
371
  }
368
372
  export function setRunShowcase(id, showcase) {
369
373
  db.prepare('UPDATE runs SET showcase = ? WHERE id = ?').run(showcase ? 1 : 0, Number(id));
@@ -388,11 +392,11 @@ export function listShowcaseRuns() {
388
392
  }
389
393
  export function countUserRunsToday(uid = getUid()) {
390
394
  const since = Date.now() - 86400000;
391
- return db.prepare("SELECT COUNT(*) AS n FROM runs WHERE mode = 'model' AND COALESCE(user_id, 1) = ? AND created_at >= ?").get(uid || 1, since)?.n || 0;
395
+ return db.prepare("SELECT COUNT(*) AS n FROM runs WHERE mode = 'model' AND COALESCE(user_id, 1) = ? AND created_at >= ?").get(uid ?? 1, since)?.n || 0;
392
396
  }
393
397
  export function countUserChatToday(uid = getUid()) {
394
398
  const since = Date.now() - 86400000;
395
- return db.prepare("SELECT COUNT(*) AS n FROM chat_messages m JOIN conversations c ON c.id = m.conv_id WHERE m.role = 'user' AND COALESCE(c.user_id, 1) = ? AND m.ts >= ?").get(uid || 1, since)?.n || 0;
399
+ return db.prepare("SELECT COUNT(*) AS n FROM chat_messages m JOIN conversations c ON c.id = m.conv_id WHERE m.role = 'user' AND COALESCE(c.user_id, 1) = ? AND m.ts >= ?").get(uid ?? 1, since)?.n || 0;
396
400
  }
397
401
 
398
402
  export function getRun(id) {
@@ -0,0 +1,103 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Загрузчик защищённой сборки НейроАльфа для компьютера клиента (Windows / Linux, один исполняемый файл).
3
+ // neuroalpha — запустить терминал и открыть его в браузере
4
+ // neuroalpha --install — добавить в автозапуск при входе в систему (и запустить)
5
+ // neuroalpha --uninstall — убрать из автозапуска (данные и активация сохраняются)
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { execFileSync, spawn } from 'node:child_process';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const req = createRequire(process.execPath);
13
+ const sea = req('node:sea');
14
+ const BUILD_INFO = __NA_BUILD__; // eslint-disable-line no-undef
15
+ const APP = 'NeuroAlpha';
16
+ const exe = process.execPath;
17
+
18
+ function dataHome() {
19
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), APP);
20
+ return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'neuroalpha');
21
+ }
22
+
23
+ // Файлы интерфейса и справочники (public/, config/, docs/) вшиты в программу и распаковываются при смене версии
24
+ function extractAssets(home) {
25
+ const dir = path.join(home, 'runtime', BUILD_INFO.version);
26
+ const marker = path.join(dir, '.complete');
27
+ if (fs.existsSync(marker)) return dir;
28
+ const manifest = JSON.parse(Buffer.from(sea.getAsset('__manifest.json')).toString('utf8'));
29
+ for (const rel of manifest) {
30
+ const dst = path.join(dir, rel);
31
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
32
+ fs.writeFileSync(dst, Buffer.from(sea.getAsset(rel)));
33
+ }
34
+ fs.writeFileSync(marker, String(Date.now()));
35
+ // старые версии удаляются
36
+ for (const v of fs.readdirSync(path.join(home, 'runtime'))) {
37
+ if (v !== BUILD_INFO.version) fs.rmSync(path.join(home, 'runtime', v), { recursive: true, force: true });
38
+ }
39
+ return dir;
40
+ }
41
+
42
+ function install() {
43
+ if (process.platform === 'win32') {
44
+ execFileSync('schtasks', ['/Create', '/F', '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TN', APP, '/TR', `"${exe}" --background`], { stdio: 'inherit' });
45
+ console.log('✓ НейроАльфа добавлена в автозапуск при входе в Windows');
46
+ } else {
47
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
48
+ fs.mkdirSync(unitDir, { recursive: true });
49
+ fs.writeFileSync(path.join(unitDir, 'neuroalpha.service'), [
50
+ '[Unit]', 'Description=NeuroAlpha trading terminal', 'After=network-online.target', '',
51
+ '[Service]', `ExecStart=${exe} --background`, 'Restart=on-failure', 'RestartSec=10', '',
52
+ '[Install]', 'WantedBy=default.target', '',
53
+ ].join('\n'));
54
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
55
+ execFileSync('systemctl', ['--user', 'enable', '--now', 'neuroalpha.service'], { stdio: 'inherit' });
56
+ console.log('✓ НейроАльфа запущена как служба пользователя (systemctl --user status neuroalpha)');
57
+ console.log(' Чтобы терминал работал без входа в систему: sudo loginctl enable-linger $USER');
58
+ }
59
+ }
60
+
61
+ function uninstall() {
62
+ if (process.platform === 'win32') {
63
+ try { execFileSync('schtasks', ['/Delete', '/F', '/TN', APP], { stdio: 'inherit' }); } catch { /* ignore */ }
64
+ } else {
65
+ try { execFileSync('systemctl', ['--user', 'disable', '--now', 'neuroalpha.service'], { stdio: 'inherit' }); } catch { /* ignore */ }
66
+ fs.rmSync(path.join(os.homedir(), '.config', 'systemd', 'user', 'neuroalpha.service'), { force: true });
67
+ }
68
+ console.log('✓ НейроАльфа убрана из автозапуска. Данные и активация сохранены в', dataHome());
69
+ }
70
+
71
+ const args = process.argv.slice(2);
72
+ if (args.includes('--version')) {
73
+ console.log(`НейроАльфа ${BUILD_INFO.version} (${BUILD_INFO.builtAt})`);
74
+ process.exit(0);
75
+ }
76
+ if (args.includes('--uninstall')) { uninstall(); process.exit(0); }
77
+ if (args.includes('--install')) {
78
+ install();
79
+ if (process.platform === 'win32') spawn(exe, ['--background'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
80
+ process.exit(0);
81
+ }
82
+
83
+ const home = dataHome();
84
+ fs.mkdirSync(home, { recursive: true });
85
+ process.env.NEUROALPHA_ROOT = extractAssets(home);
86
+ process.env.NEUROALPHA_DATA_DIR = process.env.NEUROALPHA_DATA_DIR || path.join(home, 'data');
87
+ process.env.PORT = process.env.PORT || '3000';
88
+ if (!args.includes('--background')) process.env.OPEN_BROWSER = '1';
89
+ process.chdir(process.env.NEUROALPHA_ROOT);
90
+
91
+ // Повторный запуск (двойной щелчок), когда терминал уже работает: просто открываем его в браузере
92
+ const url = `http://localhost:${process.env.PORT}`;
93
+ fetch(`${url}/healthz`, { signal: AbortSignal.timeout(1500) })
94
+ .then((r) => {
95
+ if (!r.ok) throw new Error('not ours');
96
+ if (!args.includes('--background')) {
97
+ const [cmd, a] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]] : ['xdg-open', [url]];
98
+ spawn(cmd, a, { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true }).on('error', () => {}).unref();
99
+ }
100
+ console.log(`НейроАльфа уже запущена: ${url}`);
101
+ process.exit(0);
102
+ })
103
+ .catch(() => import('./index.js'));
package/server/hub.js CHANGED
@@ -1,15 +1,29 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Общий канал событий сервера → браузер (WebSocket) и подписчики внутри сервера
3
- const listeners = new Set();
4
- let sender = () => {};
5
-
6
- export const hub = {
7
- setSender(fn) { sender = fn; },
8
- emit(msg) {
9
- sender(msg);
10
- for (const l of listeners) {
11
- try { l(msg); } catch (e) { console.error(e); }
12
- }
13
- },
14
- on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
15
- };
2
+ // Общий канал событий сервера → браузер (WebSocket) и подписчики внутри сервера.
3
+ // Каждое событие адресуется пользователю, в контексте которого оно возникло (uid),
4
+ // и уходит только в его WebSocket-соединения. Общерыночные события рассылаются всем.
5
+ import { currentUid } from './db.js';
6
+
7
+ const listeners = new Set();
8
+ let sender = () => {};
9
+
10
+ const GLOBAL_TYPES = new Set(['news:update', 'profiles:progress', 'profiles:done', 'llm:changed']);
11
+
12
+ function isGlobal(msg) {
13
+ if (GLOBAL_TYPES.has(msg.type)) return true;
14
+ // пульс радара/обучения общий; пульс по конкретному счёту — только владельцу счёта
15
+ return msg.type === 'trading:pulse' && !msg.accountId;
16
+ }
17
+
18
+ export const hub = {
19
+ setSender(fn) { sender = fn; },
20
+ emit(msg) {
21
+ const uid = msg.uid ?? currentUid();
22
+ const global = isGlobal(msg);
23
+ try { sender(msg, { uid, global }); } catch (e) { console.error(e); }
24
+ for (const l of listeners) {
25
+ try { l(msg, { uid, global }); } catch (e) { console.error(e); }
26
+ }
27
+ },
28
+ on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
29
+ };
package/server/index.js CHANGED
@@ -6,15 +6,21 @@ import path from 'node:path';
6
6
  import express from 'express';
7
7
  import { WebSocketServer } from 'ws';
8
8
  import { ROOT, PORT } from './config.js';
9
+ import { ROLE, IS_INSTANCE, IS_DESKTOP, IS_CENTRAL, INSTANCE_KIND, LISTEN_HOST } from './role.js';
10
+ import { startInstanceAgent, setInstanceCommandHandler } from './instance/agent.js';
11
+ import { getIdentity, isActivated } from './instance/identity.js';
12
+ import { startCentralReconciler } from './instance/central.js';
13
+ import { DEFAULT_CLOUD_URL } from './config.js';
9
14
  import { activeModelInfo } from './llm/client.js';
10
15
  import { markStaleRuns, removeLegacyData, removeOrphanEvents, ensureSeedRun } from './db.js';
11
16
  import { createRouter } from './api/routes.js';
12
- import { authMiddleware, activateLocalLicense } from './auth/index.js';
17
+ import { authMiddleware, activateLocalLicense, userFromRequest, runWithUser, getUserById } from './auth/index.js';
18
+ import { securityHeaders, setAppCsp, isCrossOrigin } from './security.js';
13
19
  import { live } from './model/live.js';
14
20
  import { runningResearch, runningStability } from './model/research.js';
15
21
  import { startNews } from './news/index.js';
16
22
  import { startNotifyPolling } from './notify/index.js';
17
- import { startBrokerScheduler } from './brokers/index.js';
23
+ import { startBrokerScheduler, brokersState, executeBrokerOrders, rejectBrokerOrders } from './brokers/index.js';
18
24
  import { chatState } from './strategist/index.js';
19
25
  import { hub } from './hub.js';
20
26
 
@@ -24,31 +30,93 @@ removeOrphanEvents();
24
30
  ensureSeedRun();
25
31
 
26
32
  const app = express();
33
+ app.disable('x-powered-by');
27
34
  app.use(express.json({ limit: '1mb' }));
35
+ app.use(securityHeaders);
28
36
  app.use(authMiddleware);
29
37
  const server = http.createServer(app);
30
38
  const wss = new WebSocketServer({ server, path: '/ws' });
31
39
 
32
- hub.setSender((msg) => {
40
+ hub.setSender((msg, { uid, global }) => {
33
41
  const data = JSON.stringify(msg);
34
- for (const c of wss.clients) if (c.readyState === 1) c.send(data);
42
+ for (const c of wss.clients) {
43
+ if (c.readyState !== 1) continue;
44
+ if (global || c.uid === uid) c.send(data);
45
+ }
35
46
  });
36
47
 
37
- wss.on('connection', (ws) => {
38
- ws.send(JSON.stringify({ type: 'hello', live: live.status(), chat: chatState(), running: runningResearch(), stabilityRunning: runningStability(), llm: activeModelInfo() }));
48
+ wss.on('connection', (ws, req) => {
49
+ if (isCrossOrigin(req)) {
50
+ ws.close(4403, 'forbidden origin');
51
+ return;
52
+ }
53
+ const user = userFromRequest(req);
54
+ if (!user) {
55
+ ws.close(4401, 'unauthorized');
56
+ return;
57
+ }
58
+ ws.uid = user.id;
59
+ runWithUser(user, () => {
60
+ ws.send(JSON.stringify({ type: 'hello', live: live.status(), chat: chatState(), running: runningResearch(), stabilityRunning: runningStability(), llm: activeModelInfo() }));
61
+ });
39
62
  });
40
63
 
64
+ // Кнопки «Исполнить / Отклонить» из Telegram / MAX, пришедшие с центра: только для того же самого ожидающего пакета
65
+ setInstanceCommandHandler((cmd) => runWithUser(getUserById(1), async () => {
66
+ if (cmd.type !== 'batch') return null;
67
+ const acc = brokersState().accounts.find((a) => a.id === cmd.accountId);
68
+ if (!acc) return 'Команда из мессенджера: счёт не найден.';
69
+ const pb = acc.pendingBatch;
70
+ if (!pb || pb.status !== 'pending' || (cmd.batchId && pb.id !== cmd.batchId)) {
71
+ return `Счёт «${acc.name}»: этот пакет заявок уже неактуален (исполнен, отклонён или заменён новым) — команда не выполнена.`;
72
+ }
73
+ if (cmd.action === 'exec') {
74
+ const st = await executeBrokerOrders(acc.id);
75
+ const res = st.accounts.find((a) => a.id === acc.id)?.pendingBatch?.results || [];
76
+ const ok = res.filter((r) => r.ok).length;
77
+ return `✓ Счёт «${acc.name}»: отправлено брокеру ${ok} из ${res.length} заявок.${ok < res.length ? ` Ошибки: ${res.filter((r) => !r.ok).map((r) => `${r.symbol} — ${r.error}`).join('; ').slice(0, 600)}` : ''}`;
78
+ }
79
+ if (cmd.action === 'rej') {
80
+ rejectBrokerOrders(acc.id);
81
+ return `✕ Счёт «${acc.name}»: пакет заявок отклонён.`;
82
+ }
83
+ return null;
84
+ }));
85
+ startInstanceAgent();
86
+ if (IS_CENTRAL) startCentralReconciler({ centralUrl: String(process.env.CENTRAL_PUBLIC_URL || DEFAULT_CLOUD_URL).replace(/\/+$/, '') });
41
87
  live.resume();
42
88
  startNews();
43
89
  startNotifyPolling();
44
90
  startBrokerScheduler();
45
91
 
46
- if (process.env.NEUROALPHA_API_KEY) {
92
+ if (process.env.NEUROALPHA_API_KEY && !IS_INSTANCE) {
47
93
  activateLocalLicense(process.env.NEUROALPHA_API_KEY, process.env.CLOUD_URL || '')
48
94
  .then((lic) => console.log(`✓ Активирован API-ключ: тариф «${lic.planName}» (${lic.email})`))
49
95
  .catch((e) => console.warn(`⚠ Проверка API-ключа: ${e.message}`));
50
96
  }
51
97
 
98
+ // Проверка живости для Kubernetes (liveness/readiness), без данных
99
+ app.get('/healthz', (req, res) => res.json({ ok: true, role: ROLE }));
100
+
101
+ // Машина клиента: витрина, оферта, админка и вход живут в центре; без сессии — вход через кабинет центра
102
+ if (IS_INSTANCE) {
103
+ const central = () => getIdentity().centralUrl;
104
+ const toCentral = (p) => (req, res) => res.redirect(`${central()}${p}`);
105
+ app.get(['/landing', '/landing.html'], toCentral('/landing'));
106
+ app.get(['/legal', '/legal.html'], toCentral('/legal'));
107
+ app.get(['/admin', '/admin.html'], toCentral('/landing'));
108
+ app.get(['/', '/index.html'], (req, res, next) => {
109
+ // установка на компьютер до активации — экран ввода кода из личного кабинета
110
+ if (IS_DESKTOP && !isActivated()) {
111
+ setAppCsp(res);
112
+ return res.sendFile(path.join(ROOT, 'public', 'activate.html'));
113
+ }
114
+ if (!req.user) return res.redirect(`${central()}/api/instances/open?i=${encodeURIComponent(getIdentity().id)}`);
115
+ setAppCsp(res);
116
+ next();
117
+ });
118
+ }
119
+
52
120
  app.get('/landing', (req, res) => res.sendFile(path.join(ROOT, 'public', 'landing.html')));
53
121
  app.get('/legal', (req, res) => res.sendFile(path.join(ROOT, 'public', 'legal.html')));
54
122
  app.get('/admin', (req, res) => {
@@ -56,11 +124,12 @@ app.get('/admin', (req, res) => {
56
124
  return res.sendFile(path.join(ROOT, 'public', 'admin.html'));
57
125
  });
58
126
  app.get('/', (req, res, next) => {
59
- // В CLOUD_MODE=1 неавторизованным гостям показывается лендинг (витрина PaaS),
127
+ // Неавторизованным гостям показывается лендинг со входом (витрина PaaS),
60
128
  // а авторизованным пользователям открывается их изолированный облачный терминал (или витрина при ?landing=1)
61
- if (process.env.CLOUD_MODE === '1' && (!req.user || req.query.landing === '1')) {
129
+ if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1')) {
62
130
  return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
63
131
  }
132
+ setAppCsp(res);
64
133
  next();
65
134
  });
66
135
  app.use('/api', createRouter());
@@ -80,11 +149,12 @@ server.on('error', (e) => {
80
149
  process.exit(1);
81
150
  });
82
151
 
83
- server.listen(PORT, () => {
84
- console.log('Консорциум агентов запущен');
152
+ server.listen(PORT, LISTEN_HOST, () => {
153
+ const ident = IS_INSTANCE ? getIdentity() : null;
154
+ console.log(`Консорциум агентов запущен · режим ${ROLE}${IS_INSTANCE ? ` (${INSTANCE_KIND}) · машина ${ident.id || 'не активирована'} · центр ${ident.centralUrl}` : ''}`);
85
155
  const llm = activeModelInfo();
86
156
  console.log(`ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (нет модели или ключа: Настройки → Модели ИИ)'}`);
87
- for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
157
+ if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
88
158
  console.log(`Клиент: http://localhost:${PORT}`);
89
159
  if (process.env.OPEN_BROWSER === '1') openBrowser(`http://localhost:${PORT}`);
90
160
  });