neuroalpha 0.3.17 → 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 (39) hide show
  1. package/config/certs/russian_trusted_root_ca.pem +33 -0
  2. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +4 -4
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -4
  4. 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
  5. package/package.json +60 -53
  6. package/public/activate.html +36 -0
  7. package/public/activate.js +33 -0
  8. package/public/admin.html +126 -7
  9. package/public/app.js +573 -66
  10. package/public/index.html +48 -21
  11. package/public/landing.html +106 -37
  12. package/public/legal.html +4 -4
  13. package/public/styles.css +152 -36
  14. package/server/api/routes.js +308 -37
  15. package/server/auth/index.js +79 -15
  16. package/server/brokers/index.js +1014 -168
  17. package/server/build-info.js +7 -0
  18. package/server/config.js +13 -6
  19. package/server/data/moex.js +6 -4
  20. package/server/db.js +22 -18
  21. package/server/desktop-entry.js +103 -0
  22. package/server/hub.js +28 -14
  23. package/server/index.js +82 -12
  24. package/server/instance/agent.js +259 -0
  25. package/server/instance/central.js +491 -0
  26. package/server/instance/identity.js +79 -0
  27. package/server/instance/provision.js +166 -0
  28. package/server/instance/signing.js +71 -0
  29. package/server/model/edge.js +19 -0
  30. package/server/model/forecast.js +107 -57
  31. package/server/model/live.js +1 -1
  32. package/server/model/models.js +12 -3
  33. package/server/model/research.js +218 -30
  34. package/server/notify/index.js +51 -16
  35. package/server/role.js +44 -0
  36. package/server/security.js +40 -0
  37. package/server/strategist/index.js +86 -47
  38. package/server/strategist/tools.js +231 -42
  39. package/server/tls-ca.js +32 -0
@@ -0,0 +1,259 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Агент арендованной машины клиента: раз в минуту отправляет в центр техническое состояние
3
+ // и получает подписанную лицензию (тариф, срок, разрешение торговли). Без действующей лицензии
4
+ // новые заявки не отправляются; защитные стоп-заявки по открытым позициям продолжают работать
5
+ // (кроме аварийной остановки «halted» из центра).
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { ROOT, DATA_DIR } from '../config.js';
10
+ import { db, kvGet, kvSet, userKvGet } from '../db.js';
11
+ import { IS_INSTANCE, IS_DESKTOP, INSTANCE_KIND } from '../role.js';
12
+ import { verifyToken } from './signing.js';
13
+ import { getIdentity, isActivated, saveDesktopIdentity, installId } from './identity.js';
14
+
15
+ const HEARTBEAT_MS = 60000;
16
+ const VERSION = (() => {
17
+ try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || ''; } catch { return ''; }
18
+ })();
19
+ const startedAt = Date.now();
20
+
21
+ let license = null; // проверенная полезная нагрузка лицензии
22
+ let lastError = '';
23
+ const recentErrors = [];
24
+
25
+ function noteError(msg) {
26
+ const m = String(msg || '').slice(0, 200);
27
+ if (m && m !== lastError) console.warn(`⚠ Связь с центром: ${m}`);
28
+ lastError = m;
29
+ if (m) {
30
+ recentErrors.unshift(`${new Date().toISOString()} ${m}`);
31
+ recentErrors.length = Math.min(recentErrors.length, 5);
32
+ }
33
+ }
34
+
35
+ // ---------- команды из мессенджера (кнопки «Исполнить / Отклонить»), доставленные в подписанной лицензии ----------
36
+ let commandHandler = null;
37
+ export function setInstanceCommandHandler(fn) { commandHandler = fn; }
38
+ const CMD_DONE_KEY = 'instanceCmdsDone';
39
+ const doneCmds = () => (kvGet(CMD_DONE_KEY) || []).filter((x) => Date.now() - x.ts < 3 * 3600000);
40
+
41
+ async function runCommands(cmds = []) {
42
+ if (!Array.isArray(cmds) || !cmds.length || !commandHandler) return;
43
+ const done = doneCmds();
44
+ const seen = new Set(done.map((x) => x.id));
45
+ for (const cmd of cmds.slice(0, 20)) {
46
+ if (!cmd?.id || seen.has(cmd.id)) continue;
47
+ // отмечаем до выполнения: повторная доставка той же команды не исполнит пакет дважды
48
+ done.push({ id: cmd.id, ts: Date.now() });
49
+ seen.add(cmd.id);
50
+ kvSet(CMD_DONE_KEY, done);
51
+ let reply;
52
+ try { reply = await commandHandler(cmd); } catch (e) { reply = `Ошибка выполнения команды: ${e.message}`; }
53
+ if (reply) relayNotifyToCentral(String(reply)).catch(() => {});
54
+ }
55
+ }
56
+
57
+ function acceptLicenseToken(token, { runCmds = false } = {}) {
58
+ const p = verifyToken(token, { typ: 'license', aud: getIdentity().id });
59
+ license = p;
60
+ if (runCmds && p.cmds?.length) setImmediate(() => runCommands(p.cmds).catch(() => {}));
61
+ kvSet('instanceLicenseToken', token);
62
+ // локальный пользователь машины — клиент (не администратор платформы)
63
+ db.prepare("UPDATE users SET role = 'user', email = ?, name = ?, plan = ?, plan_until = ?, offer_accepted_at = ? WHERE id = 1")
64
+ .run(String(p.email || 'client@instance.local'), String(p.name || 'Клиент'), String(p.plan || 'none'), Number(p.planUntil) || 0, Number(p.offerAcceptedAt) || 0);
65
+ // ключ ИИ-прокси центра (провайдер «НейроАльфа Облако» берёт apiKey отсюда)
66
+ kvSet('cloudLicense', {
67
+ apiKey: p.apiKey || '',
68
+ cloudUrl: getIdentity().centralUrl,
69
+ email: p.email,
70
+ name: p.name,
71
+ plan: p.plan,
72
+ planName: p.planName,
73
+ planUntil: p.planUntil || 0,
74
+ verifiedAt: Date.now(),
75
+ });
76
+ return p;
77
+ }
78
+
79
+ export function currentInstanceLicense() {
80
+ if (!license) return null;
81
+ if (!(license.exp * 1000 > Date.now())) return null;
82
+ return license;
83
+ }
84
+
85
+ // Разрешения торговли. Вне режима instance ограничений нет.
86
+ export function tradingGate() {
87
+ if (!IS_INSTANCE) return { allowNew: true, allowProtective: true, reason: '' };
88
+ if (!isActivated()) return { allowNew: false, allowProtective: true, reason: 'Программа не активирована — введите код активации из личного кабинета' };
89
+ const lic = currentInstanceLicense();
90
+ if (!lic) return { allowNew: false, allowProtective: true, reason: 'Нет действующей лицензии центра — новые заявки приостановлены (защитные стопы работают)' };
91
+ if (lic.trading === 'halted') return { allowNew: false, allowProtective: false, reason: 'Торговля остановлена администратором платформы' };
92
+ if (lic.revoked) return { allowNew: false, allowProtective: true, reason: 'Аренда машины завершена — новые заявки не отправляются' };
93
+ if (lic.trading === 'paused') return { allowNew: false, allowProtective: true, reason: 'Торговля приостановлена администратором платформы (защитные стопы работают)' };
94
+ if (!lic.plan || lic.plan === 'none') return { allowNew: false, allowProtective: true, reason: 'Подписка не активна — новые заявки приостановлены' };
95
+ return { allowNew: true, allowProtective: true, reason: '' };
96
+ }
97
+
98
+ // ---------- техническое состояние (без сумм, позиций, токенов и персональных данных) ----------
99
+ function collectStatus() {
100
+ let list = [];
101
+ try {
102
+ list = db.prepare('SELECT data FROM broker_accounts WHERE user_id = 1').all().map((r) => JSON.parse(r.data));
103
+ } catch {
104
+ const accs = userKvGet('brokerAccounts', [], 1);
105
+ list = Array.isArray(accs) ? accs : [];
106
+ }
107
+ const brokers = {};
108
+ for (const a of list) brokers[a.broker || 'unknown'] = (brokers[a.broker || 'unknown'] || 0) + 1;
109
+ const syncTs = list.map((a) => Number(a.lastSync?.at) || 0).filter(Boolean);
110
+ let dbMb = 0;
111
+ try { dbMb = Math.round(fs.statSync(path.join(DATA_DIR, 'trading.db')).size / 1048576); } catch { /* ignore */ }
112
+ const fcAt = Number(kvGet('lastForecastSnap')?.result?.at || 0);
113
+ const gate = tradingGate();
114
+ const lic = currentInstanceLicense();
115
+ return {
116
+ version: VERSION,
117
+ node: process.version,
118
+ uptimeSec: Math.round((Date.now() - startedAt) / 1000),
119
+ rssMb: Math.round(process.memoryUsage().rss / 1048576),
120
+ dbMb,
121
+ accounts: list.length,
122
+ autoAccounts: list.filter((a) => a.mode === 'auto').length,
123
+ pausedAccounts: list.filter((a) => a.autoSchedule === false || a.mode === 'readonly').length,
124
+ haltedAccounts: list.filter((a) => a.haltedReason).length,
125
+ brokers,
126
+ lastSyncAgoSec: syncTs.length ? Math.round((Date.now() - Math.max(...syncTs)) / 1000) : null,
127
+ forecastAgoSec: fcAt ? Math.round((Date.now() - fcAt) / 1000) : null,
128
+ tradingAllowed: gate.allowNew,
129
+ tradingReason: gate.reason,
130
+ licenseLeftSec: lic ? Math.round(lic.exp - Date.now() / 1000) : 0,
131
+ errors: recentErrors.slice(0, 5),
132
+ host: os.hostname().slice(0, 60),
133
+ };
134
+ }
135
+
136
+ async function centralPost(pathname, body, timeoutMs = 20000, { withKey = true } = {}) {
137
+ const id = getIdentity();
138
+ const r = await fetch(`${id.centralUrl}${pathname}`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json', ...(withKey ? { Authorization: `Bearer ${id.key}` } : {}) },
141
+ body: JSON.stringify(body || {}),
142
+ signal: AbortSignal.timeout(timeoutMs),
143
+ });
144
+ const j = await r.json().catch(() => ({}));
145
+ if (!r.ok) throw Object.assign(new Error(j.error || `HTTP ${r.status}`), { status: r.status });
146
+ return j;
147
+ }
148
+
149
+ // Адрес терминала на компьютере клиента (для входа из кабинета центра одноразовой ссылкой)
150
+ const localUrl = () => `http://localhost:${Number(process.env.PORT || 3000)}`;
151
+
152
+ let updateInfo = null; // { latestVersion, downloadUrl } — если центр сообщил о новой версии
153
+
154
+ export function instanceInfo() {
155
+ const id = getIdentity();
156
+ const lic = currentInstanceLicense();
157
+ return {
158
+ kind: INSTANCE_KIND,
159
+ activated: isActivated(),
160
+ id: id.id,
161
+ centralUrl: id.centralUrl,
162
+ version: VERSION,
163
+ licenseValidTill: lic ? lic.exp * 1000 : 0,
164
+ lastError,
165
+ update: updateInfo,
166
+ };
167
+ }
168
+
169
+ let hbBusy = false;
170
+ export async function heartbeatNow() {
171
+ if (hbBusy || !isActivated()) return license;
172
+ hbBusy = true;
173
+ try {
174
+ const j = await centralPost('/api/instance/heartbeat', {
175
+ status: collectStatus(),
176
+ installId: installId(),
177
+ kind: INSTANCE_KIND,
178
+ ack: doneCmds().map((x) => x.id),
179
+ ...(IS_DESKTOP ? { localUrl: localUrl() } : {}),
180
+ });
181
+ if (!j.license) throw new Error('Центр не выдал лицензию');
182
+ acceptLicenseToken(j.license, { runCmds: true });
183
+ updateInfo = j.latestVersion && j.latestVersion !== VERSION ? { latestVersion: j.latestVersion, downloadUrl: j.downloadUrl || '' } : null;
184
+ if (lastError) console.log('✓ Связь с центром восстановлена, лицензия обновлена');
185
+ lastError = '';
186
+ } catch (e) {
187
+ // центр отверг ключ (отвязан, заменён или машина удалена) — лицензия отзывается сразу, без ожидания срока
188
+ if (e.status === 401 || e.status === 409) {
189
+ license = null;
190
+ kvSet('instanceLicenseToken', null);
191
+ }
192
+ noteError(e.message);
193
+ } finally {
194
+ hbBusy = false;
195
+ }
196
+ return license;
197
+ }
198
+
199
+ // ---------- одноразовый вход с центра (SSO) ----------
200
+ const usedSso = new Map(); // jti -> exp
201
+ export function consumeSsoToken(token) {
202
+ const p = verifyToken(token, { typ: 'sso', aud: getIdentity().id, leewaySec: 30 });
203
+ const now = Math.floor(Date.now() / 1000);
204
+ for (const [k, exp] of usedSso) if (exp < now - 300) usedSso.delete(k);
205
+ if (!p.jti || usedSso.has(p.jti)) throw new Error('Ссылка входа уже использована — откройте терминал из кабинета ещё раз');
206
+ usedSso.set(p.jti, p.exp);
207
+ return p;
208
+ }
209
+
210
+ // ---------- уведомления клиенту через центр (боты Telegram / MAX живут только в центре) ----------
211
+ // после уведомления с кнопками 15 минут связываемся с центром каждые 5 с, чтобы нажатие выполнилось быстро
212
+ let fastUntil = 0;
213
+ export async function relayNotifyToCentral(text, buttons = []) {
214
+ if (buttons?.length) fastUntil = Date.now() + 15 * 60000;
215
+ try {
216
+ const j = await centralPost('/api/instance/notify', { text: String(text || '').slice(0, 3500), buttons: buttons || [] }, 15000);
217
+ return { tg: Boolean(j.tg), max: Boolean(j.max) };
218
+ } catch (e) {
219
+ noteError(`уведомление: ${e.message}`);
220
+ return { tg: false, max: false };
221
+ }
222
+ }
223
+
224
+ // ---------- активация установки на компьютере кодом из личного кабинета ----------
225
+ export async function activateDesktop(code) {
226
+ if (!IS_DESKTOP) throw new Error('Активация кодом доступна только для установки на компьютер');
227
+ const clean = String(code || '').trim().toUpperCase();
228
+ if (!/^[A-Z0-9-]{8,24}$/.test(clean)) throw new Error('Введите код активации из личного кабинета (вида NA-XXXX-XXXX)');
229
+ const j = await centralPost('/api/instance/activate', {
230
+ code: clean,
231
+ installId: installId(),
232
+ localUrl: localUrl(),
233
+ hostname: os.hostname().slice(0, 60),
234
+ version: VERSION,
235
+ }, 20000, { withKey: false });
236
+ if (!j.instanceId || !j.instanceKey) throw new Error('Центр не выдал ключ установки');
237
+ saveDesktopIdentity({ id: j.instanceId, key: j.instanceKey, centralUrl: getIdentity().centralUrl, publicKeyPem: j.publicKey || '' });
238
+ license = null;
239
+ await heartbeatNow();
240
+ if (!currentInstanceLicense()) throw new Error(lastError || 'Ключ получен, но лицензия не выдана — проверьте подписку в кабинете');
241
+ return instanceInfo();
242
+ }
243
+
244
+ export function startInstanceAgent() {
245
+ if (!IS_INSTANCE) return;
246
+ db.prepare("UPDATE users SET role = 'user' WHERE id = 1 AND role = 'admin'").run();
247
+ const saved = kvGet('instanceLicenseToken');
248
+ if (saved) {
249
+ try { acceptLicenseToken(saved); } catch (e) { noteError(`сохранённая лицензия: ${e.message}`); }
250
+ }
251
+ const loop = async () => {
252
+ await heartbeatNow().catch(() => {});
253
+ const t = setTimeout(loop, Date.now() < fastUntil ? 5000 : HEARTBEAT_MS);
254
+ t.unref?.();
255
+ };
256
+ loop();
257
+ }
258
+
259
+ export const INSTANCE_VERSION = VERSION;