neuroalpha 0.6.12 → 0.7.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 (46) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +79 -69
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
  4. package/package.json +4 -5
  5. package/public/activate.html +41 -19
  6. package/public/activate.js +81 -10
  7. package/public/app.js +300 -152
  8. package/public/index.html +4 -37
  9. package/server/api/routes.js +570 -906
  10. package/server/auth/index.js +576 -761
  11. package/server/brokers/index.js +1 -4
  12. package/server/config.js +73 -53
  13. package/server/index.js +186 -187
  14. package/server/llm/local-engine.js +843 -0
  15. package/server/llm/providers.js +45 -34
  16. package/server/model/dataset.js +2 -4
  17. package/server/model/forecast.js +1 -3
  18. package/server/news/index.js +2 -3
  19. package/server/notify/index.js +304 -466
  20. package/deploy/docs/01-central.md +0 -96
  21. package/deploy/docs/02-cloud-instances.md +0 -130
  22. package/deploy/docs/03-desktop-build.md +0 -92
  23. package/deploy/docs/04-client-guide.md +0 -101
  24. package/deploy/docs/05-operations.md +0 -109
  25. package/deploy/docs/06-env-reference.md +0 -91
  26. package/deploy/docs/07-stress-test.md +0 -253
  27. package/deploy/docs/pdf/01-central.pdf +0 -0
  28. package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
  29. package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
  30. package/deploy/docs/pdf/04-client-guide.pdf +0 -0
  31. package/deploy/docs/pdf/05-operations.pdf +0 -0
  32. package/deploy/docs/pdf/06-env-reference.pdf +0 -0
  33. package/deploy/docs/pdf/07-stress-test.pdf +0 -0
  34. package/deploy/install.sh +0 -128
  35. package/deploy/k8s/instance.yaml +0 -147
  36. package/public/admin.html +0 -604
  37. package/public/landing.html +0 -1207
  38. package/public/legal.html +0 -134
  39. package/scripts/set-admin-password.mjs +0 -30
  40. package/server/billing/yookassa.js +0 -142
  41. package/server/instance/agent.js +0 -259
  42. package/server/instance/central.js +0 -514
  43. package/server/instance/identity.js +0 -79
  44. package/server/instance/provision.js +0 -166
  45. package/server/instance/signing.js +0 -71
  46. package/server/role.js +0 -44
@@ -1,761 +1,576 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { AsyncLocalStorage } from 'node:async_hooks';
6
- import { ROOT, DATA_DIR, DEFAULT_CLOUD_URL } from '../config.js';
7
- import { db, kvGet, kvSet, registerUidResolver } from '../db.js';
8
- import { IS_INSTANCE, IS_DESKTOP } from '../role.js';
9
- import { currentInstanceLicense } from '../instance/agent.js';
10
- import { isActivated } from '../instance/identity.js';
11
-
12
- const ctx = new AsyncLocalStorage();
13
- // Фоновые задачи сервера (без контекста запроса) работают от имени владельца (id = 1).
14
- // Запрос без авторизованного пользователя получает uid = 0 — изолированное пустое пространство данных.
15
- const resolveUid = () => {
16
- const store = ctx.getStore();
17
- if (!store) return 1;
18
- return store.user?.id || 0;
19
- };
20
- registerUidResolver(resolveUid);
21
-
22
- // ---------- шифрование секретов (AES-256-GCM) ----------
23
- let masterKey = null;
24
- function getMasterKey() {
25
- if (masterKey) return masterKey;
26
- const envKey = (process.env.MASTER_KEY || '').trim();
27
- if (envKey) {
28
- masterKey = crypto.createHash('sha256').update(envKey).digest();
29
- return masterKey;
30
- }
31
- const keyPath = path.join(DATA_DIR, '.master.key');
32
- try {
33
- if (fs.existsSync(keyPath)) {
34
- const hex = fs.readFileSync(keyPath, 'utf8').trim();
35
- if (hex.length >= 64) { masterKey = Buffer.from(hex.slice(0, 64), 'hex'); return masterKey; }
36
- }
37
- const rnd = crypto.randomBytes(32);
38
- fs.mkdirSync(path.dirname(keyPath), { recursive: true });
39
- fs.writeFileSync(keyPath, rnd.toString('hex'), { mode: 0o600 });
40
- masterKey = rnd;
41
- } catch {
42
- masterKey = crypto.createHash('sha256').update(ROOT).digest();
43
- }
44
- return masterKey;
45
- }
46
-
47
- export function encryptSecret(plain) {
48
- const s = String(plain ?? '').trim();
49
- if (!s) return '';
50
- if (s.startsWith('enc:v1:')) return s;
51
- const iv = crypto.randomBytes(12);
52
- const cipher = crypto.createCipheriv('aes-256-gcm', getMasterKey(), iv);
53
- const enc = Buffer.concat([cipher.update(s, 'utf8'), cipher.final()]);
54
- const tag = cipher.getAuthTag();
55
- return `enc:v1:${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
56
- }
57
-
58
- export function decryptSecret(val) {
59
- const s = String(val ?? '').trim();
60
- if (!s) return '';
61
- if (!s.startsWith('enc:v1:')) return s;
62
- try {
63
- const [, , ivHex, tagHex, encHex] = s.split(':');
64
- const decipher = crypto.createDecipheriv('aes-256-gcm', getMasterKey(), Buffer.from(ivHex, 'hex'));
65
- decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
66
- return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8');
67
- } catch {
68
- return '';
69
- }
70
- }
71
-
72
- // ---------- хэширование паролей (scrypt) ----------
73
- export function hashPassword(password) {
74
- const salt = crypto.randomBytes(16).toString('hex');
75
- const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
76
- return `${salt}:${hash}`;
77
- }
78
-
79
- export async function verifyPassword(password, stored) {
80
- if (!stored || !stored.includes(':')) return false;
81
- const [salt, hex] = stored.split(':');
82
- const hash = await new Promise((resolve, reject) => {
83
- crypto.scrypt(String(password), salt, 64, (err, key) => (err ? reject(err) : resolve(key)));
84
- });
85
- const target = Buffer.from(hex, 'hex');
86
- return hash.length === target.length && crypto.timingSafeEqual(hash, target);
87
- }
88
-
89
- // ---------- ограничение попыток входа (по IP и по email) ----------
90
- const LOGIN_WINDOW_MS = 15 * 60000;
91
- const LOGIN_MAX_FAILS = 8;
92
- const loginFails = new Map(); // key -> { count, firstAt }
93
-
94
- function loginBlockedFor(key) {
95
- const rec = loginFails.get(key);
96
- if (!rec) return 0;
97
- const left = rec.firstAt + LOGIN_WINDOW_MS - Date.now();
98
- if (left <= 0) { loginFails.delete(key); return 0; }
99
- return rec.count >= LOGIN_MAX_FAILS ? left : 0;
100
- }
101
-
102
- function noteLoginFail(key) {
103
- const now = Date.now();
104
- const rec = loginFails.get(key);
105
- if (!rec || now - rec.firstAt > LOGIN_WINDOW_MS) loginFails.set(key, { count: 1, firstAt: now });
106
- else rec.count += 1;
107
- if (loginFails.size > 50000) {
108
- for (const [k, v] of loginFails) if (now - v.firstAt > LOGIN_WINDOW_MS) loginFails.delete(k);
109
- }
110
- }
111
-
112
- // ---------- пользователи, тарифные планы (Инвестор, Профи, VIP + режим Хозяин) и сессии ----------
113
- export const PLANS = {
114
- none: {
115
- id: 'none',
116
- name: 'Без подписки',
117
- priceRub: 0,
118
- maxRunsPerDay: 0,
119
- maxVirtualAccounts: 0,
120
- maxBrokerAccounts: 0,
121
- maxCapitalPerAccountRub: 0,
122
- allowShorts: false,
123
- allowVipBoost: false,
124
- allowResearch: false,
125
- maxChatPerDay: 10,
126
- },
127
- investor: {
128
- id: 'investor',
129
- name: 'Инвестор',
130
- priceRub: 3900,
131
- maxRunsPerDay: 0,
132
- maxVirtualAccounts: 0,
133
- maxBrokerAccounts: 1,
134
- maxCapitalPerAccountRub: 1000000,
135
- allowShorts: false,
136
- allowVipBoost: false,
137
- allowResearch: false,
138
- maxChatPerDay: 200,
139
- },
140
- pro: {
141
- id: 'pro',
142
- name: 'Профи',
143
- priceRub: 9900,
144
- maxRunsPerDay: 0,
145
- maxVirtualAccounts: 0,
146
- maxBrokerAccounts: 5,
147
- maxCapitalPerAccountRub: 1000000,
148
- allowShorts: true,
149
- allowVipBoost: false,
150
- allowResearch: false,
151
- maxChatPerDay: 1000,
152
- },
153
- vip: {
154
- id: 'vip',
155
- name: 'VIP',
156
- priceRub: 0, // по индивидуальному согласованию / VIP-токену от администратора
157
- customPrice: true,
158
- maxRunsPerDay: 0,
159
- maxVirtualAccounts: 0,
160
- maxBrokerAccounts: 50,
161
- maxCapitalPerAccountRub: 1000000000,
162
- allowShorts: true,
163
- allowVipBoost: true,
164
- allowResearch: false,
165
- allowDesktop: true, // установка на свой компьютер — по договору (VIP)
166
- maxChatPerDay: 5000,
167
- },
168
- owner: {
169
- id: 'owner',
170
- name: 'Хозяин (Без ограничений)',
171
- priceRub: 0,
172
- isOwner: true,
173
- maxRunsPerDay: 10000,
174
- maxVirtualAccounts: 20,
175
- maxBrokerAccounts: 100,
176
- maxCapitalPerAccountRub: 1000000000,
177
- allowShorts: true,
178
- allowVipBoost: true,
179
- allowResearch: true,
180
- allowDesktop: true,
181
- maxChatPerDay: 10000,
182
- },
183
- };
184
-
185
- export function generateApiKey() {
186
- return `na_live_${crypto.randomBytes(16).toString('hex')}`;
187
- }
188
-
189
- export function ensureApiKey(userId, regenerate = false) {
190
- const u = getUserById(userId);
191
- if (!u) throw new Error('Пользователь не найден');
192
- if (u.api_key && !regenerate) return u.api_key;
193
- const key = generateApiKey();
194
- db.prepare('UPDATE users SET api_key = ? WHERE id = ?').run(key, u.id);
195
- return key;
196
- }
197
-
198
- export function getUserByApiKey(apiKey) {
199
- const clean = String(apiKey || '').trim();
200
- if (!clean.startsWith('na_live_')) return null;
201
- return db.prepare('SELECT * FROM users WHERE api_key = ?').get(clean) || null;
202
- }
203
-
204
- // ---------- синхронизация лицензии для локального запуска (npx neuroalpha --key ...) ----------
205
- export function getLocalLicense() {
206
- return kvGet('cloudLicense') || null;
207
- }
208
-
209
- export async function activateLocalLicense(apiKey, cloudUrl = '') {
210
- const cleanKey = String(apiKey || '').trim();
211
- if (!cleanKey.startsWith('na_live_')) throw new Error('Ключ должен начинаться с na_live_…');
212
- const baseUrl = String(cloudUrl || process.env.CLOUD_URL || DEFAULT_CLOUD_URL).trim().replace(/\/+$/, '');
213
- return patchUserLicense(cleanKey, baseUrl);
214
- }
215
-
216
- async function patchUserLicense(cleanKey, baseUrl) {
217
- // 1) если ключ есть в этой же БД — проверяем напрямую
218
- const localUser = getUserByApiKey(cleanKey);
219
- if (localUser) {
220
- const p = effectivePlan(localUser);
221
- const lic = {
222
- apiKey: cleanKey,
223
- cloudUrl: baseUrl || 'local',
224
- email: localUser.email,
225
- name: localUser.name,
226
- plan: p.id,
227
- planName: p.name,
228
- planUntil: localUser.plan_until || 0,
229
- verifiedAt: Date.now(),
230
- };
231
- kvSet('cloudLicense', lic);
232
- return lic;
233
- }
234
- // 2) иначе проверяем об облачный сервер
235
- if (!baseUrl) throw new Error('Ключ не найден локально. Укажите адрес облачного сервера или активируйте ключ на сайте');
236
- const r = await fetch(`${baseUrl}/api/license/verify`, {
237
- method: 'POST',
238
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cleanKey}` },
239
- body: JSON.stringify({ apiKey: cleanKey }),
240
- signal: AbortSignal.timeout(15000),
241
- });
242
- const j = await r.json().catch(() => ({}));
243
- if (!r.ok || !j.valid) throw new Error(j.error || `Не удалось проверить ключ (HTTP ${r.status})`);
244
- const lic = {
245
- apiKey: cleanKey,
246
- cloudUrl: baseUrl,
247
- email: j.email,
248
- name: j.name,
249
- plan: j.plan || 'free',
250
- planName: j.planName || PLANS[j.plan]?.name || 'Старт',
251
- planUntil: j.planUntil || 0,
252
- verifiedAt: Date.now(),
253
- };
254
- kvSet('cloudLicense', lic);
255
- // применяем тариф к локальному пользователю #1
256
- db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = 1').run(lic.plan, lic.planUntil);
257
- return lic;
258
- }
259
-
260
- export function effectivePlan(user) {
261
- if (!user) return PLANS.none;
262
- // Машина клиента: тариф только из действующей подписанной лицензии центра
263
- if (IS_INSTANCE) {
264
- if (user.id !== 1) return PLANS.none;
265
- const lic = currentInstanceLicense();
266
- if (!lic || lic.revoked) return PLANS.none;
267
- const p = PLANS[lic.plan] || PLANS.none;
268
- if (p.id !== 'none' && lic.planUntil > 0 && lic.planUntil < Date.now()) return PLANS.none;
269
- return p;
270
- }
271
- if (process.env.CLOUD_MODE !== '1' && user.id === 1) {
272
- if (user.plan && PLANS[user.plan] && user.plan !== 'owner') {
273
- if (user.plan !== 'none' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.none;
274
- return PLANS[user.plan];
275
- }
276
- const lic = getLocalLicense();
277
- if (lic?.plan && PLANS[lic.plan]) {
278
- if (lic.plan !== 'none' && lic.planUntil > 0 && lic.planUntil < Date.now()) return PLANS.none;
279
- return PLANS[lic.plan];
280
- }
281
- if (process.env.LOCAL_STRICT_LICENSE === '1') return PLANS.none;
282
- return PLANS.owner;
283
- }
284
- if (user.id === 1 || user.role === 'admin' || user.plan === 'owner') return PLANS.owner;
285
- const p = PLANS[user.plan] || PLANS.none;
286
- if (p.id !== 'none' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.none;
287
- return p;
288
- }
289
-
290
- export function publicUser(u) {
291
- if (!u) return null;
292
- if (!u.api_key) {
293
- try { u.api_key = ensureApiKey(u.id); } catch { /* ignore */ }
294
- }
295
- const plan = effectivePlan(u);
296
- const isOwner = Boolean(u.role === 'admin' || plan.id === 'owner' || plan.allowResearch);
297
- return {
298
- id: u.id,
299
- email: u.email,
300
- name: u.name || 'Инвестор',
301
- role: u.role || 'user',
302
- isOwner,
303
- plan: plan.id,
304
- planName: plan.name,
305
- planUntil: u.plan_until || 0,
306
- limits: plan,
307
- apiKey: u.api_key || null,
308
- tgConnected: Boolean(u.tg_chat_id),
309
- maxConnected: Boolean(u.max_chat_id),
310
- notifyCode: u.notify_code || null,
311
- offerAcceptedAt: u.offer_accepted_at || 0,
312
- localLicense: process.env.CLOUD_MODE !== '1' ? getLocalLicense() : null,
313
- createdAt: u.created_at,
314
- };
315
- }
316
-
317
- export function acceptUserOffer(userId) {
318
- if (!userId) throw new Error('Войдите в аккаунт для подписания оферты');
319
- const now = Date.now();
320
- db.prepare('UPDATE users SET offer_accepted_at = ? WHERE id = ?').run(now, Number(userId));
321
- return publicUser(getUserById(userId));
322
- }
323
-
324
- export function getUserById(id) {
325
- return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
326
- }
327
-
328
- export function currentUser() {
329
- const store = ctx.getStore();
330
- if (store) return store.user || null;
331
- return getUserById(1);
332
- }
333
-
334
- export function currentUserId() {
335
- return resolveUid();
336
- }
337
-
338
- // Локальный режим без входа допускается только с этого же компьютера (не из локальной сети и не через прокси).
339
- // LOCAL_TRUST_LAN=1 возвращает прежнее поведение (доступ владельца любому устройству в сети).
340
- export function isLoopbackRequest(req) {
341
- if (process.env.LOCAL_TRUST_LAN === '1') return true;
342
- if (req?.headers?.['x-forwarded-for'] || req?.headers?.['x-real-ip'] || req?.headers?.forwarded) return false;
343
- const addr = String(req?.socket?.remoteAddress || '');
344
- if (!(addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1')) return false;
345
- // защита от DNS-rebinding: чужой домен, указывающий на 127.0.0.1, не получает доступ владельца
346
- const host = String(req?.headers?.host || '').toLowerCase().replace(/:\d+$/, '');
347
- return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
348
- }
349
-
350
- export function runWithUser(user, fn) {
351
- return ctx.run({ user }, fn);
352
- }
353
-
354
- export function parseCookies(cookieHeader = '') {
355
- const out = {};
356
- for (const part of String(cookieHeader || '').split(';')) {
357
- const i = part.indexOf('=');
358
- if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
359
- }
360
- return out;
361
- }
362
-
363
- export function parseAllCookieValues(cookieHeader = '', key = 'sid') {
364
- const vals = [];
365
- for (const part of String(cookieHeader || '').split(';')) {
366
- const i = part.indexOf('=');
367
- if (i > 0 && part.slice(0, i).trim() === key) {
368
- vals.push(decodeURIComponent(part.slice(i + 1).trim()));
369
- }
370
- }
371
- return vals;
372
- }
373
-
374
- export function userFromRequest(req) {
375
- const cookies = parseCookies(req.headers?.cookie);
376
- const allSids = parseAllCookieValues(req.headers?.cookie, 'sid');
377
- const authHeader = String(req.headers?.authorization || '');
378
- const apiKeyHeader = String(req.headers?.['x-api-key'] || '').trim();
379
- const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
380
- const apiCandidate = apiKeyHeader || (bearer.startsWith('na_live_') ? bearer : '');
381
- if (apiCandidate) {
382
- const byKey = getUserByApiKey(apiCandidate);
383
- if (byKey) return byKey;
384
- }
385
- const candidates = [...allSids.filter((s) => s && s !== 'logged_out'), ...(bearer ? [bearer] : [])];
386
- for (const token of candidates) {
387
- const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
388
- if (sess) {
389
- const u = getUserById(sess.user_id);
390
- if (u) return u;
391
- }
392
- }
393
- // В локальном режиме (без CLOUD_MODE=1) владелец (id = 1) открывается без входа только с этого компьютера
394
- // Установка на компьютер клиента после активации — так же, без пароля только с этого компьютера
395
- const localTrusted = !IS_INSTANCE || (IS_DESKTOP && isActivated());
396
- const explicitlyLoggedOut = cookies.sid === 'logged_out' || allSids.includes('logged_out');
397
- if (process.env.CLOUD_MODE !== '1' && localTrusted && !explicitlyLoggedOut && isLoopbackRequest(req)) {
398
- return getUserById(1);
399
- }
400
- return null;
401
- }
402
-
403
- export function createSession(userId, days = 30) {
404
- const token = crypto.randomBytes(24).toString('hex');
405
- const now = Date.now();
406
- db.prepare('INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)')
407
- .run(token, Number(userId), now, now + days * 86400000);
408
- return token;
409
- }
410
-
411
- export function deleteSession(token) {
412
- if (!token) return;
413
- db.prepare('DELETE FROM sessions WHERE token = ?').run(String(token));
414
- }
415
-
416
- export function registerUser({ email, password, name }) {
417
- const cleanEmail = String(email || '').trim().toLowerCase();
418
- if (!cleanEmail || !cleanEmail.includes('@')) throw new Error('Укажите корректный email');
419
- if (!password || String(password).length < 6) throw new Error('Пароль должен быть не короче 6 символов');
420
- const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(cleanEmail);
421
- if (existing) throw new Error('Пользователь с таким email уже зарегистрирован');
422
- const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
423
- const apiKey = generateApiKey();
424
- const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, created_at)
425
- VALUES (?, ?, ?, 'user', 'none', 0, ?, ?, ?)`).run(cleanEmail, String(name || cleanEmail.split('@')[0]).trim(), hashPassword(password), code, apiKey, Date.now());
426
- return getUserById(Number(r.lastInsertRowid));
427
- }
428
-
429
- export async function loginUser({ email, password }, { ip = '' } = {}) {
430
- const cleanEmail = String(email || '').trim().toLowerCase();
431
- const keys = [`e:${cleanEmail}`, ...(ip ? [`ip:${ip}`] : [])];
432
- const blockedMs = Math.max(...keys.map(loginBlockedFor));
433
- if (blockedMs > 0) {
434
- throw new Error(`Слишком много неудачных попыток входа. Повторите через ${Math.ceil(blockedMs / 60000)} мин.`);
435
- }
436
- const u = db.prepare('SELECT * FROM users WHERE email = ?').get(cleanEmail);
437
- const ok = u ? await verifyPassword(password, u.pass_hash) : false;
438
- if (!ok) {
439
- for (const k of keys) noteLoginFail(k);
440
- throw new Error('Неверный email или пароль');
441
- }
442
- for (const k of keys) loginFails.delete(k);
443
- return u;
444
- }
445
-
446
- export function ensureNotifyCode(userId) {
447
- const u = getUserById(userId);
448
- if (!u) throw new Error('Пользователь не найден');
449
- if (u.notify_code) return u.notify_code;
450
- const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
451
- db.prepare('UPDATE users SET notify_code = ? WHERE id = ?').run(code, u.id);
452
- return code;
453
- }
454
-
455
- export function listAllUsers() {
456
- return db.prepare('SELECT * FROM users ORDER BY id ASC').all().map(publicUser);
457
- }
458
-
459
- export function setUserPlan(userId, plan, days = 30) {
460
- if (!(plan in PLANS)) throw new Error(`Неизвестный тариф: ${plan}`);
461
- const uid = Number(userId);
462
- if (!uid) throw new Error('Некорректный ID пользователя');
463
- const until = (plan === 'none' || plan === 'owner') ? 0 : Date.now() + Math.max(1, Number(days) || 30) * 86400000;
464
- // Тариф «Хозяин» (owner) даёт права администратора (role = 'admin'); главный владелец (id = 1) всегда сохраняет роль admin
465
- const role = (plan === 'owner' || uid === 1) ? 'admin' : 'user';
466
- db.prepare('UPDATE users SET plan = ?, plan_until = ?, role = ? WHERE id = ?').run(plan, until, role, uid);
467
- return publicUser(getUserById(uid));
468
- }
469
-
470
- export function createOrUpdateAdminUser({ email, password, name }) {
471
- const cleanEmail = String(email || '').trim().toLowerCase();
472
- if (!cleanEmail || !cleanEmail.includes('@')) throw new Error('Укажите корректный email администратора');
473
- const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(cleanEmail);
474
- const cleanPass = String(password || '').trim();
475
- if (existing) {
476
- if (cleanPass && cleanPass.length < 6) throw new Error('Пароль должен быть не короче 6 символов');
477
- const nextHash = cleanPass ? hashPassword(cleanPass) : existing.pass_hash;
478
- const nextName = String(name || '').trim() || existing.name || 'Администратор';
479
- db.prepare("UPDATE users SET name = ?, pass_hash = ?, role = 'admin', plan = 'owner', plan_until = 0 WHERE id = ?")
480
- .run(nextName, nextHash, existing.id);
481
- ensureApiKey(existing.id);
482
- return publicUser(getUserById(existing.id));
483
- }
484
- if (cleanPass.length < 6) throw new Error('Для нового администратора укажите пароль не короче 6 символов');
485
- const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
486
- const apiKey = generateApiKey();
487
- const cleanName = String(name || '').trim() || 'Администратор';
488
- const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, offer_accepted_at, created_at)
489
- VALUES (?, ?, ?, 'admin', 'owner', 0, ?, ?, ?, ?)`).run(cleanEmail, cleanName, hashPassword(cleanPass), code, apiKey, Date.now(), Date.now());
490
- return publicUser(getUserById(Number(r.lastInsertRowid)));
491
- }
492
-
493
- // ---------- промокоды и персональные VIP-токены ----------
494
- export function createPromoCode({ code, plan = 'investor', days = 14, maxUses = 50, generateVip = false }) {
495
- let clean = String(code || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '');
496
- if (generateVip || (!clean && plan === 'vip')) {
497
- clean = `VIP-${crypto.randomBytes(3).toString('hex').toUpperCase()}-${crypto.randomBytes(2).toString('hex').toUpperCase()}`;
498
- }
499
- if (clean.length < 3) throw new Error('Код должен содержать минимум 3 символа (латиница/цифры)');
500
- if (!(plan in PLANS) || plan === 'none') throw new Error('Выберите тариф Инвестор, Профи или VIP');
501
- const d = Math.max(1, Math.min(3650, Math.round(Number(days) || (plan === 'vip' ? 365 : 14))));
502
- const m = Math.max(1, Math.min(10000, Math.round(Number(maxUses) || (plan === 'vip' ? 1 : 50))));
503
- db.prepare(`INSERT OR REPLACE INTO promocodes (code, plan, days, max_uses, used_count, created_at)
504
- VALUES (?, ?, ?, ?, COALESCE((SELECT used_count FROM promocodes WHERE code = ?), 0), ?)`).run(clean, plan, d, m, clean, Date.now());
505
- return listPromoCodes();
506
- }
507
-
508
- export function listPromoCodes() {
509
- return db.prepare('SELECT * FROM promocodes ORDER BY created_at DESC').all();
510
- }
511
-
512
- export function redeemPromoCode(userId, rawCode) {
513
- if (!userId) throw new Error('Войдите в аккаунт для активации кода или VIP-токена');
514
- const clean = String(rawCode || '').trim().toUpperCase();
515
- const promo = db.prepare('SELECT * FROM promocodes WHERE UPPER(code) = ?').get(clean);
516
- if (!promo) throw new Error('Промокод или VIP-токен не найден');
517
- if (promo.used_count >= promo.max_uses) throw new Error('Лимит активаций этого кода или VIP-токена исчерпан');
518
- const already = db.prepare('SELECT 1 FROM promocode_uses WHERE code = ? AND user_id = ?').get(promo.code, Number(userId));
519
- if (already) throw new Error('Вы уже активировали этот код');
520
- const u = getUserById(userId);
521
- const baseTs = u.plan === promo.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
522
- const nextUntil = baseTs + promo.days * 86400000;
523
- if (u.id === 1 || u.role === 'admin') {
524
- db.prepare('UPDATE users SET offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(Date.now(), u.id);
525
- } else {
526
- db.prepare('UPDATE users SET plan = ?, plan_until = ?, offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(promo.plan, nextUntil, Date.now(), u.id);
527
- }
528
- db.prepare('UPDATE promocodes SET used_count = used_count + 1 WHERE code = ?').run(promo.code);
529
- db.prepare('INSERT INTO promocode_uses (code, user_id, used_at) VALUES (?, ?, ?)').run(promo.code, u.id, Date.now());
530
- return publicUser(getUserById(userId));
531
- }
532
-
533
- export function createVipRequest({ user, name, contact, capitalRub, comment }) {
534
- const list = kvGet('vipRequests') || [];
535
- const item = {
536
- id: `vipreq_${Date.now()}`,
537
- createdAt: Date.now(),
538
- userId: user?.id || null,
539
- email: user?.email || '',
540
- name: String(name || user?.name || '').trim() || 'Инвестор',
541
- contact: String(contact || '').trim(),
542
- capitalRub: String(capitalRub || '').trim(),
543
- comment: String(comment || '').trim(),
544
- status: 'new',
545
- };
546
- if (!item.contact && !item.email) throw new Error('Укажите ваш Telegram, телефон или email для связи');
547
- list.unshift(item);
548
- if (list.length > 200) list.length = 200;
549
- kvSet('vipRequests', list);
550
- return item;
551
- }
552
-
553
- export function listVipRequests() {
554
- return kvGet('vipRequests') || [];
555
- }
556
-
557
- export function authMiddleware(req, res, next) {
558
- const u = userFromRequest(req);
559
- req.user = u;
560
- return runWithUser(u, () => next());
561
- }
562
-
563
- // ---------- OAuth 2.0 / Социальный вход: Яндекс, Google, Сбер ID, Т-Банк ID, VK ID ----------
564
- export const OAUTH_PROVIDERS = [
565
- { id: 'yandex', name: 'Яндекс ID', foreign: false, authUrl: 'https://oauth.yandex.ru/authorize', tokenUrl: 'https://oauth.yandex.ru/token', userUrl: 'https://login.yandex.ru/info?format=json', scope: 'login:email login:info' },
566
- { id: 'google', name: 'Google', foreign: true, authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', scope: 'openid email profile' },
567
- { id: 'sber', name: 'Сбер ID', foreign: false, authUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oauth', tokenUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oidc', userUrl: 'https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo', scope: 'openid name email' },
568
- { id: 'tbank', name: 'T-ID (Т-Банк)', foreign: false, authUrl: 'https://id.tbank.ru/auth/authorize', tokenUrl: 'https://id.tbank.ru/auth/token', userUrl: 'https://id.tbank.ru/userinfo/userinfo', scope: 'openid profile email' },
569
- { id: 'vk', name: 'VK ID', foreign: false, authUrl: 'https://oauth.vk.com/authorize', tokenUrl: 'https://oauth.vk.com/access_token', userUrl: 'https://api.vk.com/method/users.get?v=5.199', scope: 'email' },
570
- ];
571
-
572
- export function getOAuthConfig() {
573
- const saved = kvGet('oauthConfig') || {};
574
- const out = {};
575
- for (const p of OAUTH_PROVIDERS) {
576
- const envPrefix = `OAUTH_${p.id.toUpperCase()}_`;
577
- const cur = saved[p.id] || {};
578
- const clientId = String(cur.clientId || process.env[`${envPrefix}CLIENT_ID`] || '').trim();
579
- const clientSecret = decryptSecret(cur.clientSecret || '') || String(process.env[`${envPrefix}CLIENT_SECRET`] || '').trim();
580
- out[p.id] = { id: p.id, name: p.name, foreign: Boolean(p.foreign), clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
581
- }
582
- return out;
583
- }
584
-
585
- export function publicOAuthConfig() {
586
- const cfg = getOAuthConfig();
587
- return Object.fromEntries(OAUTH_PROVIDERS.map((p) => [p.id, {
588
- id: p.id,
589
- name: p.name,
590
- foreign: Boolean(p.foreign),
591
- clientId: cfg[p.id].clientId,
592
- hasSecret: Boolean(cfg[p.id].clientSecret),
593
- enabled: cfg[p.id].enabled,
594
- }]));
595
- }
596
-
597
- export function saveOAuthConfig(input = {}) {
598
- const prev = kvGet('oauthConfig') || {};
599
- const next = { ...prev };
600
- for (const p of OAUTH_PROVIDERS) {
601
- const b = input[p.id];
602
- if (!b) continue;
603
- const cur = prev[p.id] || {};
604
- next[p.id] = {
605
- clientId: String(b.clientId ?? cur.clientId ?? '').trim(),
606
- clientSecret: b.clearSecret ? '' : (b.clientSecret && String(b.clientSecret).trim() ? encryptSecret(b.clientSecret) : (cur.clientSecret || '')),
607
- };
608
- }
609
- kvSet('oauthConfig', next);
610
- return publicOAuthConfig();
611
- }
612
-
613
- const oauthStates = new Map(); // state -> { provider, plan, createdAt, origin }
614
-
615
- export function startOAuthRedirect(providerId, { origin = 'http://localhost:3000', plan = '' } = {}) {
616
- const meta = OAUTH_PROVIDERS.find((p) => p.id === providerId);
617
- if (!meta) throw new Error(`Неизвестный провайдер: ${providerId}`);
618
- const cfg = getOAuthConfig()[providerId];
619
- if (!cfg?.enabled) {
620
- throw new Error(`Вход через «${meta.name}» ещё не подключён администратором (укажите Client ID и Secret в Настройках → Профиль, тариф и боты)`);
621
- }
622
- // очистка старых state
623
- const now = Date.now();
624
- for (const [k, v] of oauthStates) if (now - v.createdAt > 15 * 60000) oauthStates.delete(k);
625
-
626
- const state = crypto.randomBytes(16).toString('hex');
627
- const redirectUri = `${origin.replace(/\/+$/, '')}/api/auth/oauth/${providerId}/callback`;
628
- oauthStates.set(state, { provider: providerId, plan: String(plan || ''), createdAt: now, redirectUri });
629
-
630
- const params = new URLSearchParams({
631
- client_id: cfg.clientId,
632
- redirect_uri: redirectUri,
633
- response_type: 'code',
634
- scope: meta.scope,
635
- state,
636
- });
637
- if (providerId === 'sber') params.set('nonce', crypto.randomUUID());
638
- return `${meta.authUrl}?${params.toString()}`;
639
- }
640
-
641
- function decodeJwtPayload(jwt) {
642
- try {
643
- const part = String(jwt || '').split('.')[1];
644
- if (!part) return null;
645
- return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
646
- } catch {
647
- return null;
648
- }
649
- }
650
-
651
- export async function completeOAuthCallback(providerId, { code, state }) {
652
- const st = oauthStates.get(String(state || ''));
653
- oauthStates.delete(String(state || ''));
654
- if (!st || st.provider !== providerId) throw new Error('Сессия авторизации истекла — попробуйте войти ещё раз');
655
- if (!code) throw new Error('Провайдер не передал код авторизации');
656
-
657
- const meta = OAUTH_PROVIDERS.find((p) => p.id === providerId);
658
- const cfg = getOAuthConfig()[providerId];
659
- if (!meta || !cfg?.enabled) throw new Error('Провайдер не настроен');
660
-
661
- const body = new URLSearchParams({
662
- grant_type: 'authorization_code',
663
- code: String(code),
664
- client_id: cfg.clientId,
665
- client_secret: cfg.clientSecret,
666
- redirect_uri: st.redirectUri,
667
- });
668
-
669
- const tokRes = await fetch(meta.tokenUrl, {
670
- method: 'POST',
671
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
672
- body: body.toString(),
673
- signal: AbortSignal.timeout(15000),
674
- });
675
- const tokJson = await tokRes.json().catch(() => ({}));
676
- if (!tokRes.ok || (!tokJson.access_token && !tokJson.id_token)) {
677
- throw new Error(tokJson.error_description || tokJson.error || `Ошибка обмена токена ${meta.name} (HTTP ${tokRes.status})`);
678
- }
679
-
680
- let email = '';
681
- let name = '';
682
- let extId = '';
683
-
684
- if (providerId === 'vk') {
685
- email = String(tokJson.email || '').trim().toLowerCase();
686
- extId = String(tokJson.user_id || '');
687
- if (tokJson.access_token && extId) {
688
- const uRes = await fetch(`${meta.userUrl}&access_token=${encodeURIComponent(tokJson.access_token)}&user_ids=${encodeURIComponent(extId)}`).then((r) => r.json()).catch(() => null);
689
- const u0 = uRes?.response?.[0];
690
- if (u0) name = `${u0.first_name || ''} ${u0.last_name || ''}`.trim();
691
- }
692
- } else {
693
- if (tokJson.id_token) {
694
- const claims = decodeJwtPayload(tokJson.id_token);
695
- if (claims) {
696
- email = String(claims.email || '').trim().toLowerCase();
697
- name = String(claims.name || claims.given_name || '').trim();
698
- extId = String(claims.sub || '');
699
- }
700
- }
701
- if ((!email || !name) && tokJson.access_token && meta.userUrl) {
702
- const uRes = await fetch(meta.userUrl, {
703
- headers: { Authorization: `Bearer ${tokJson.access_token}`, Accept: 'application/json' },
704
- signal: AbortSignal.timeout(12000),
705
- }).then((r) => r.json()).catch(() => null);
706
- if (uRes) {
707
- email = email || String(uRes.default_email || uRes.email || uRes.emails?.[0] || '').trim().toLowerCase();
708
- name = name || String(uRes.real_name || uRes.display_name || uRes.name || `${uRes.first_name || ''} ${uRes.last_name || ''}`).trim();
709
- extId = extId || String(uRes.id || uRes.sub || uRes.psuid || '');
710
- }
711
- }
712
- }
713
-
714
- if (!email) {
715
- email = `${providerId}_${extId || crypto.randomBytes(4).toString('hex')}@oauth.neuroalpha.local`;
716
- }
717
- if (!name) {
718
- name = email.split('@')[0];
719
- }
720
-
721
- let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
722
- if (!user) {
723
- const notifyCode = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
724
- const apiKey = generateApiKey();
725
- const rndPass = hashPassword(crypto.randomBytes(16).toString('hex'));
726
- const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, created_at)
727
- VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?)`).run(email, name, rndPass, notifyCode, apiKey, Date.now());
728
- user = getUserById(Number(r.lastInsertRowid));
729
- } else if (!user.api_key) {
730
- ensureApiKey(user.id);
731
- user = getUserById(user.id);
732
- }
733
-
734
- return { user, plan: st.plan };
735
- }
736
-
737
- export function verifyTelegramLoginWidget(query = {}, botToken = '') {
738
- const { hash, ...data } = query;
739
- if (!hash || !botToken) throw new Error('Некорректные данные авторизации Telegram');
740
- const checkString = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n');
741
- const secretKey = crypto.createHash('sha256').update(botToken).digest();
742
- const hmac = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');
743
- if (hmac !== String(hash)) throw new Error('Подпись Telegram не прошла проверку');
744
- const tgId = String(data.id || '');
745
- const name = `${data.first_name || ''} ${data.last_name || ''}`.trim() || data.username || `Telegram ${tgId}`;
746
- const email = data.username ? `${String(data.username).toLowerCase()}@tg.neuroalpha.local` : `tg_${tgId}@tg.neuroalpha.local`;
747
-
748
- let user = db.prepare('SELECT * FROM users WHERE tg_chat_id = ? OR email = ?').get(tgId, email);
749
- if (!user) {
750
- const notifyCode = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
751
- const apiKey = generateApiKey();
752
- const rndPass = hashPassword(crypto.randomBytes(16).toString('hex'));
753
- const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, tg_chat_id, notify_code, api_key, created_at)
754
- VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?, ?)`).run(email, name, rndPass, tgId, notifyCode, apiKey, Date.now());
755
- user = getUserById(Number(r.lastInsertRowid));
756
- } else if (!user.tg_chat_id) {
757
- db.prepare('UPDATE users SET tg_chat_id = ? WHERE id = ?').run(tgId, user.id);
758
- user = getUserById(user.id);
759
- }
760
- return user;
761
- }
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Однопользовательский контур безопасности и автономного лицензирования торгового терминала НейроАльфа:
3
+ // • шифрование локальных брокерских ключей (AES-256-GCM в ~/.neuroalpha/.master.key);
4
+ // • активация терминала по токену личного кабинета (na_tok_...) и офлайн-проверка подписи лицензии (Ed25519);
5
+ // • 100% автономная работа без обращений к порталу до последних 3 суток перед истечением оплаченного срока;
6
+ // • защита удалённого доступа (на VPS): с localhost (127.0.0.1) вход открыт после активации, по сети требуется токен ЛК или локальный пароль.
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { AsyncLocalStorage } from 'node:async_hooks';
12
+ import { ROOT, DATA_DIR, IS_DEV_REPO, DEFAULT_CLOUD_URL } from '../config.js';
13
+ import { IS_PACKAGED, BUILD } from '../build-info.js';
14
+ import { db, kvGet, kvSet, registerUidResolver } from '../db.js';
15
+
16
+ const ctx = new AsyncLocalStorage();
17
+ const resolveUid = () => {
18
+ const store = ctx.getStore();
19
+ if (!store) return 1;
20
+ return store.user?.id || 0;
21
+ };
22
+ registerUidResolver(resolveUid);
23
+
24
+ const DAY_MS = 86400000;
25
+ const RENEW_WINDOW_MS = 3 * DAY_MS;
26
+
27
+ export const PLANS = {
28
+ none: {
29
+ id: 'none',
30
+ name: 'Без активной лицензии',
31
+ priceRub: 0,
32
+ maxRunsPerDay: 0,
33
+ maxVirtualAccounts: 0,
34
+ maxBrokerAccounts: 0,
35
+ maxCapitalPerAccountRub: 0,
36
+ allowShorts: false,
37
+ allowVipBoost: false,
38
+ allowResearch: false,
39
+ maxChatPerDay: 10,
40
+ },
41
+ investor: {
42
+ id: 'investor',
43
+ name: 'Инвестор',
44
+ priceRub: 3900,
45
+ maxRunsPerDay: 0,
46
+ maxVirtualAccounts: 1,
47
+ maxBrokerAccounts: 1,
48
+ maxCapitalPerAccountRub: 1000000,
49
+ allowShorts: false,
50
+ allowVipBoost: false,
51
+ allowResearch: false,
52
+ maxChatPerDay: 200,
53
+ },
54
+ pro: {
55
+ id: 'pro',
56
+ name: 'Профи',
57
+ priceRub: 9900,
58
+ maxRunsPerDay: 0,
59
+ maxVirtualAccounts: 3,
60
+ maxBrokerAccounts: 5,
61
+ maxCapitalPerAccountRub: 1000000,
62
+ allowShorts: true,
63
+ allowVipBoost: false,
64
+ allowResearch: false,
65
+ maxChatPerDay: 1000,
66
+ },
67
+ vip: {
68
+ id: 'vip',
69
+ name: 'VIP',
70
+ priceRub: 0,
71
+ customPrice: true,
72
+ maxRunsPerDay: 0,
73
+ maxVirtualAccounts: 10,
74
+ maxBrokerAccounts: 50,
75
+ maxCapitalPerAccountRub: 1000000000,
76
+ allowShorts: true,
77
+ allowVipBoost: true,
78
+ allowResearch: false,
79
+ maxChatPerDay: 5000,
80
+ },
81
+ owner: {
82
+ id: 'owner',
83
+ name: 'Хозяин (Без ограничений)',
84
+ priceRub: 0,
85
+ isOwner: true,
86
+ maxRunsPerDay: 10000,
87
+ maxVirtualAccounts: 20,
88
+ maxBrokerAccounts: 100,
89
+ maxCapitalPerAccountRub: 1000000000,
90
+ allowShorts: true,
91
+ allowVipBoost: true,
92
+ allowResearch: true,
93
+ maxChatPerDay: 10000,
94
+ },
95
+ };
96
+
97
+ // ---------- AES-256-GCM ----------
98
+ let masterKey = null;
99
+ function getMasterKey() {
100
+ if (masterKey) return masterKey;
101
+ const envKey = String(process.env.MASTER_KEY || '').trim();
102
+ if (envKey) {
103
+ masterKey = crypto.createHash('sha256').update(envKey).digest();
104
+ return masterKey;
105
+ }
106
+ const keyPath = path.join(DATA_DIR, '.master.key');
107
+ try {
108
+ if (fs.existsSync(keyPath)) {
109
+ const hex = fs.readFileSync(keyPath, 'utf8').trim();
110
+ if (hex.length >= 64) { masterKey = Buffer.from(hex.slice(0, 64), 'hex'); return masterKey; }
111
+ }
112
+ const rnd = crypto.randomBytes(32);
113
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
114
+ fs.writeFileSync(keyPath, rnd.toString('hex'), { mode: 0o600 });
115
+ masterKey = rnd;
116
+ } catch {
117
+ masterKey = crypto.createHash('sha256').update(ROOT).digest();
118
+ }
119
+ return masterKey;
120
+ }
121
+
122
+ export function encryptSecret(plain) {
123
+ const s = String(plain ?? '').trim();
124
+ if (!s) return '';
125
+ if (s.startsWith('enc:v1:')) return s;
126
+ const iv = crypto.randomBytes(12);
127
+ const cipher = crypto.createCipheriv('aes-256-gcm', getMasterKey(), iv);
128
+ const enc = Buffer.concat([cipher.update(s, 'utf8'), cipher.final()]);
129
+ const tag = cipher.getAuthTag();
130
+ return `enc:v1:${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
131
+ }
132
+
133
+ export function decryptSecret(val) {
134
+ const s = String(val ?? '').trim();
135
+ if (!s) return '';
136
+ if (!s.startsWith('enc:v1:')) return s;
137
+ try {
138
+ const [, , ivHex, tagHex, encHex] = s.split(':');
139
+ const decipher = crypto.createDecipheriv('aes-256-gcm', getMasterKey(), Buffer.from(ivHex, 'hex'));
140
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
141
+ return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8');
142
+ } catch {
143
+ return '';
144
+ }
145
+ }
146
+
147
+ const sha256Hex = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
148
+
149
+ export function hashPassword(password) {
150
+ const salt = crypto.randomBytes(16).toString('hex');
151
+ const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
152
+ return `${salt}:${hash}`;
153
+ }
154
+
155
+ export async function verifyPassword(password, stored) {
156
+ if (!stored || !stored.includes(':')) return false;
157
+ const [salt, hex] = stored.split(':');
158
+ const hash = await new Promise((resolve, reject) => {
159
+ crypto.scrypt(String(password), salt, 64, (err, key) => (err ? reject(err) : resolve(key)));
160
+ });
161
+ const target = Buffer.from(hex, 'hex');
162
+ return hash.length === target.length && crypto.timingSafeEqual(hash, target);
163
+ }
164
+
165
+ // ---------- Уникальный идентификатор установки ПК / VPS ----------
166
+ let cachedInstallId = '';
167
+ export function getInstallId() {
168
+ if (cachedInstallId) return cachedInstallId;
169
+ const idFile = path.join(DATA_DIR, '.install-id');
170
+ try {
171
+ if (fs.existsSync(idFile)) {
172
+ const v = fs.readFileSync(idFile, 'utf8').trim();
173
+ if (v.length >= 16) { cachedInstallId = v; return v; }
174
+ }
175
+ const next = `inst_${crypto.randomBytes(16).toString('hex')}`;
176
+ fs.mkdirSync(path.dirname(idFile), { recursive: true });
177
+ fs.writeFileSync(idFile, next, { mode: 0o600 });
178
+ cachedInstallId = next;
179
+ return next;
180
+ } catch {
181
+ cachedInstallId = `inst_${sha256Hex(DATA_DIR).slice(0, 32)}`;
182
+ return cachedInstallId;
183
+ }
184
+ }
185
+
186
+ // ---------- Офлайн-проверка подписанной лицензии Ed25519 ----------
187
+ export function verifyLicenseCertificate(licenseToken, publicKeyPem, expectedInstallId = getInstallId()) {
188
+ const pinnedPem = (IS_PACKAGED && BUILD?.publicKeyPem)
189
+ || (process.env.LICENSE_PUBLIC_KEY ? process.env.LICENSE_PUBLIC_KEY.replace(/\\n/g, '\n') : '')
190
+ || String(publicKeyPem || '').trim();
191
+ if (!pinnedPem || !pinnedPem.includes('BEGIN PUBLIC KEY')) {
192
+ throw new Error('Отсутствует открытый ключ проверки лицензии');
193
+ }
194
+ const [dataB64, sigB64] = String(licenseToken || '').split('.');
195
+ if (!dataB64 || !sigB64) throw new Error('Некорректный формат сертификата лицензии');
196
+ const pubKey = crypto.createPublicKey(pinnedPem);
197
+ const validSig = crypto.verify(null, Buffer.from(dataB64), pubKey, Buffer.from(sigB64, 'base64url'));
198
+ if (!validSig) throw new Error('Цифровая подпись лицензии не прошла проверку');
199
+
200
+ let p;
201
+ try {
202
+ p = JSON.parse(Buffer.from(dataB64, 'base64url').toString('utf8'));
203
+ } catch {
204
+ throw new Error('Повреждены данные сертификата лицензии');
205
+ }
206
+ if (p.typ !== 'na_license' || p.iss !== 'neuroalpha-portal') {
207
+ throw new Error('Сертификат выпущен неизвестным центром');
208
+ }
209
+ if (expectedInstallId && p.installId && p.installId !== expectedInstallId) {
210
+ throw new Error('Лицензия выпущена для другого компьютера');
211
+ }
212
+ return p;
213
+ }
214
+
215
+ const isDevUnactivatedBypass = () => IS_DEV_REPO && process.env.NEUROALPHA_STRICT_LICENSE !== '1';
216
+
217
+ export function getTerminalLicenseState() {
218
+ const stored = kvGet('terminalLicense') || null;
219
+ const localPassHash = kvGet('terminalLocalPassHash') || '';
220
+ if (!stored?.licenseToken || !stored?.publicKeyPem) {
221
+ const devBypass = isDevUnactivatedBypass();
222
+ return {
223
+ activated: devBypass,
224
+ devBypass,
225
+ valid: devBypass,
226
+ expired: false,
227
+ plan: devBypass ? 'owner' : 'none',
228
+ planName: devBypass ? PLANS.owner.name : PLANS.none.name,
229
+ validUntil: 0,
230
+ perpetual: devBypass,
231
+ email: devBypass ? 'dev@local' : '',
232
+ name: devBypass ? 'Разработчик (локальный репозиторий)' : '',
233
+ licId: '',
234
+ label: '',
235
+ portalUrl: stored?.portalUrl || DEFAULT_CLOUD_URL,
236
+ hasCabinetToken: Boolean(stored?.cabinetTokenEnc),
237
+ hasLocalPassword: Boolean(localPassHash),
238
+ installId: getInstallId(),
239
+ };
240
+ }
241
+
242
+ try {
243
+ const p = verifyLicenseCertificate(stored.licenseToken, stored.publicKeyPem, getInstallId());
244
+ const validUntil = Number(p.validUntil) || 0;
245
+ const perpetual = Boolean(p.perpetual || validUntil === 0);
246
+ const expired = !perpetual && validUntil <= Date.now();
247
+ const planId = !expired && PLANS[p.plan] ? p.plan : 'none';
248
+ return {
249
+ activated: true,
250
+ devBypass: false,
251
+ valid: !expired && planId !== 'none',
252
+ expired,
253
+ plan: planId,
254
+ planName: PLANS[planId]?.name || p.planName || planId,
255
+ rawPlanName: p.planName || PLANS[p.plan]?.name || p.plan,
256
+ validUntil,
257
+ perpetual,
258
+ email: p.email || '',
259
+ name: p.name || '',
260
+ licId: p.licId || stored.slotId || '',
261
+ label: p.label || '',
262
+ portalUrl: stored.portalUrl || p.portalUrl || DEFAULT_CLOUD_URL,
263
+ hasCabinetToken: Boolean(stored.cabinetTokenEnc),
264
+ hasLocalPassword: Boolean(localPassHash),
265
+ installId: getInstallId(),
266
+ lastFetchedAt: stored.fetchedAt || 0,
267
+ };
268
+ } catch (err) {
269
+ return {
270
+ activated: false,
271
+ devBypass: false,
272
+ valid: false,
273
+ expired: false,
274
+ error: err.message,
275
+ plan: 'none',
276
+ planName: PLANS.none.name,
277
+ validUntil: 0,
278
+ perpetual: false,
279
+ email: '',
280
+ name: '',
281
+ licId: '',
282
+ label: '',
283
+ portalUrl: stored.portalUrl || DEFAULT_CLOUD_URL,
284
+ hasCabinetToken: Boolean(stored.cabinetTokenEnc),
285
+ hasLocalPassword: Boolean(localPassHash),
286
+ installId: getInstallId(),
287
+ };
288
+ }
289
+ }
290
+
291
+ export function getStoredCabinetCredentials() {
292
+ const stored = kvGet('terminalLicense') || {};
293
+ const token = decryptSecret(stored.cabinetTokenEnc || '');
294
+ const portalUrl = String(stored.portalUrl || DEFAULT_CLOUD_URL).replace(/\/+$/, '');
295
+ return { token, portalUrl };
296
+ }
297
+
298
+ // Скачивание подписанной автономной лицензии с портала по токену ЛК (na_tok_...)
299
+ export async function activateTerminalByToken({ token = '', portalUrl = '', slotId = '' } = {}) {
300
+ const stored = kvGet('terminalLicense') || {};
301
+ const cleanToken = String(token || decryptSecret(stored.cabinetTokenEnc || '')).trim();
302
+ if (!cleanToken || !cleanToken.startsWith('na_tok_')) {
303
+ throw new Error('Введите корректный токен из личного кабинета (начинается с na_tok_…)');
304
+ }
305
+ const basePortal = String(
306
+ (IS_PACKAGED ? BUILD.centralUrl : (portalUrl || stored.portalUrl || DEFAULT_CLOUD_URL)),
307
+ ).trim().replace(/\/+$/, '');
308
+ if (!/^https?:\/\//i.test(basePortal)) {
309
+ throw new Error('Укажите корректный адрес портала лицензий (https://…)');
310
+ }
311
+
312
+ const r = await fetch(`${basePortal}/api/license/fetch`, {
313
+ method: 'POST',
314
+ headers: {
315
+ 'Content-Type': 'application/json',
316
+ Authorization: `Bearer ${cleanToken}`,
317
+ },
318
+ body: JSON.stringify({
319
+ token: cleanToken,
320
+ installId: getInstallId(),
321
+ hostname: os.hostname().slice(0, 64),
322
+ slotId: String(slotId || '').trim(),
323
+ }),
324
+ signal: AbortSignal.timeout(20000),
325
+ });
326
+ const j = await r.json().catch(() => ({}));
327
+ if (!r.ok) {
328
+ throw new Error(j.error || `Портал вернул ошибку HTTP ${r.status}`);
329
+ }
330
+
331
+ // Если в ЛК несколько свободных слотов лицензий — возвращаем список для выбора пользователем
332
+ if (j.chooseSlot) {
333
+ return {
334
+ chooseSlot: true,
335
+ portalUrl: basePortal,
336
+ email: j.email || '',
337
+ name: j.name || '',
338
+ slots: j.slots || [],
339
+ };
340
+ }
341
+
342
+ if (!j.licenseToken || !j.publicKeyPem) {
343
+ throw new Error('Портал не вернул подписанный сертификат лицензии');
344
+ }
345
+ const verified = verifyLicenseCertificate(j.licenseToken, j.publicKeyPem, getInstallId());
346
+
347
+ kvSet('terminalLicense', {
348
+ portalUrl: basePortal,
349
+ publicKeyPem: j.publicKeyPem,
350
+ cabinetTokenEnc: encryptSecret(cleanToken),
351
+ cabinetTokenHash: sha256Hex(cleanToken),
352
+ slotId: verified.licId || j.slot?.id || '',
353
+ licenseToken: j.licenseToken,
354
+ fetchedAt: Date.now(),
355
+ });
356
+
357
+ // Синхронизируем облачный ключ для ИИ-прокси портала и шлюза Telegram/MAX
358
+ kvSet('cloudLicense', {
359
+ apiKey: cleanToken,
360
+ cloudUrl: basePortal,
361
+ email: verified.email,
362
+ name: verified.name,
363
+ plan: verified.plan,
364
+ planName: verified.planName,
365
+ planUntil: verified.validUntil || 0,
366
+ verifiedAt: Date.now(),
367
+ });
368
+
369
+ db.prepare('UPDATE users SET email = ?, name = ?, plan = ?, plan_until = ?, offer_accepted_at = ? WHERE id = 1').run(
370
+ String(verified.email || 'user@neuroalpha.local'),
371
+ String(verified.name || 'Пользователь'),
372
+ String(verified.plan || 'none'),
373
+ Number(verified.validUntil) || 0,
374
+ Date.now(),
375
+ );
376
+
377
+ return {
378
+ chooseSlot: false,
379
+ state: getTerminalLicenseState(),
380
+ };
381
+ }
382
+
383
+ // Обратная совместимость с CLI-флагом при запуске
384
+ export async function activateLocalLicense(token, portalUrl = '') {
385
+ const out = await activateTerminalByToken({ token, portalUrl });
386
+ return out.state || out;
387
+ }
388
+
389
+ // Автономное фоновое продление: НЕ пингует портал вообще, пока до истечения лицензии остаётся > 3 суток!
390
+ let lastAutoRefreshAttemptTs = 0;
391
+ export function startAutonomousLicenseWatcher() {
392
+ const checkIfExpiringSoon = async () => {
393
+ try {
394
+ const st = getTerminalLicenseState();
395
+ if (!st.activated || st.devBypass || st.perpetual || !st.hasCabinetToken) return;
396
+ const leftMs = (Number(st.validUntil) || 0) - Date.now();
397
+ if (leftMs > RENEW_WINDOW_MS) return; // больше 3 суток впереди — полная тишина и автономность!
398
+ if (Date.now() - lastAutoRefreshAttemptTs < 12 * 3600000) return;
399
+ lastAutoRefreshAttemptTs = Date.now();
400
+ const stored = kvGet('terminalLicense') || {};
401
+ await activateTerminalByToken({ slotId: stored.slotId || '' });
402
+ } catch { /* при офлайне продолжаем работать по текущему сертификату до его конца */ }
403
+ };
404
+ const timer = setInterval(checkIfExpiringSoon, 3600000);
405
+ timer.unref?.();
406
+ }
407
+
408
+ // Предохранитель торгового движка (server/brokers/index.js):
409
+ // без активной лицензии новые входы блокируются, но защитные стопы продолжают работать!
410
+ export function tradingGate() {
411
+ const st = getTerminalLicenseState();
412
+ if (!st.activated) {
413
+ return { allowNew: false, allowProtective: true, reason: 'Терминал не активирован — введите токен из личного кабинета' };
414
+ }
415
+ if (st.expired || !st.valid) {
416
+ return { allowNew: false, allowProtective: true, reason: 'Срок действия лицензии истёк — продлите подписку в личном кабинете и обновите лицензию (защитные стопы работают)' };
417
+ }
418
+ return { allowNew: true, allowProtective: true, reason: '' };
419
+ }
420
+
421
+ // ---------- Защита входа при удалённом доступе (VPS / внешняя сеть) ----------
422
+ const loginFails = new Map();
423
+ function checkRemoteLoginRate(ip) {
424
+ if (!ip) return;
425
+ const now = Date.now();
426
+ const r = loginFails.get(ip);
427
+ if (r && now - r.firstAt < 15 * 60000 && r.count >= 8) {
428
+ const leftMin = Math.ceil((15 * 60000 - (now - r.firstAt)) / 60000);
429
+ throw new Error(`Слишком много неверных попыток входа. Повторите через ${leftMin} мин.`);
430
+ }
431
+ }
432
+
433
+ function recordRemoteFail(ip) {
434
+ if (!ip) return;
435
+ const now = Date.now();
436
+ const r = loginFails.get(ip);
437
+ if (!r || now - r.firstAt > 15 * 60000) loginFails.set(ip, { count: 1, firstAt: now });
438
+ else r.count += 1;
439
+ }
440
+
441
+ export async function loginRemoteTerminal(secretInput, { ip = '' } = {}) {
442
+ checkRemoteLoginRate(ip);
443
+ const clean = String(secretInput || '').trim();
444
+ if (!clean) throw new Error('Введите токен личного кабинета или локальный пароль терминала');
445
+
446
+ const stored = kvGet('terminalLicense') || {};
447
+ let ok = false;
448
+ if (stored.cabinetTokenHash && clean.startsWith('na_tok_')) {
449
+ const a = Buffer.from(stored.cabinetTokenHash, 'hex');
450
+ const b = Buffer.from(sha256Hex(clean), 'hex');
451
+ ok = a.length === b.length && crypto.timingSafeEqual(a, b);
452
+ }
453
+ const localPassHash = kvGet('terminalLocalPassHash') || '';
454
+ if (!ok && localPassHash) {
455
+ ok = await verifyPassword(clean, localPassHash);
456
+ }
457
+ if (!ok) {
458
+ recordRemoteFail(ip);
459
+ throw new Error('Неверный токен или локальный пароль терминала');
460
+ }
461
+ if (ip) loginFails.delete(ip);
462
+ return createSession(1, 30);
463
+ }
464
+
465
+ export function setTerminalLocalPassword(newPassword = '') {
466
+ const clean = String(newPassword || '').trim();
467
+ if (!clean) {
468
+ kvSet('terminalLocalPassHash', '');
469
+ return { hasLocalPassword: false };
470
+ }
471
+ if (clean.length < 6) throw new Error('Локальный пароль должен содержать минимум 6 символов');
472
+ kvSet('terminalLocalPassHash', hashPassword(clean));
473
+ return { hasLocalPassword: true };
474
+ }
475
+
476
+ // ---------- Текущий пользователь терминала и сессии ----------
477
+ export function getUserById(id) {
478
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
479
+ }
480
+
481
+ export function currentUser() {
482
+ const store = ctx.getStore();
483
+ if (store) return store.user || null;
484
+ return getUserById(1);
485
+ }
486
+
487
+ export function currentUserId() {
488
+ return resolveUid();
489
+ }
490
+
491
+ export function runWithUser(user, fn) {
492
+ return ctx.run({ user }, fn);
493
+ }
494
+
495
+ export function effectivePlan() {
496
+ const st = getTerminalLicenseState();
497
+ if (!st.valid) return PLANS.none;
498
+ return PLANS[st.plan] || PLANS.none;
499
+ }
500
+
501
+ export function publicUser(u) {
502
+ if (!u) return null;
503
+ const st = getTerminalLicenseState();
504
+ const plan = effectivePlan();
505
+ const isOwner = Boolean(plan.id === 'owner' || plan.allowResearch);
506
+ return {
507
+ id: 1,
508
+ email: st.email || u.email || '',
509
+ name: st.name || u.name || 'Пользователь',
510
+ role: isOwner ? 'admin' : 'user',
511
+ isOwner,
512
+ plan: plan.id,
513
+ planName: plan.name,
514
+ planUntil: st.perpetual ? 0 : (st.validUntil || 0),
515
+ perpetual: Boolean(st.perpetual),
516
+ limits: plan,
517
+ license: st,
518
+ };
519
+ }
520
+
521
+ export function isLoopbackRequest(req) {
522
+ if (process.env.LOCAL_TRUST_LAN === '1') return true;
523
+ if (req?.headers?.['x-forwarded-for'] || req?.headers?.['x-real-ip'] || req?.headers?.forwarded) return false;
524
+ const addr = String(req?.socket?.remoteAddress || '');
525
+ if (!(addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1')) return false;
526
+ const host = String(req?.headers?.host || '').toLowerCase().replace(/:\d+$/, '');
527
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
528
+ }
529
+
530
+ export function parseAllCookieValues(cookieHeader = '', key = 'sid') {
531
+ const vals = [];
532
+ for (const part of String(cookieHeader || '').split(';')) {
533
+ const i = part.indexOf('=');
534
+ if (i > 0 && part.slice(0, i).trim() === key) {
535
+ vals.push(decodeURIComponent(part.slice(i + 1).trim()));
536
+ }
537
+ }
538
+ return vals;
539
+ }
540
+
541
+ export function createSession(userId = 1, days = 30) {
542
+ const token = crypto.randomBytes(24).toString('hex');
543
+ const now = Date.now();
544
+ db.prepare('INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)')
545
+ .run(token, Number(userId), now, now + days * DAY_MS);
546
+ return token;
547
+ }
548
+
549
+ export function deleteSession(token) {
550
+ if (!token) return;
551
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(String(token));
552
+ }
553
+
554
+ export function userFromRequest(req) {
555
+ const st = getTerminalLicenseState();
556
+ if (!st.activated) return null;
557
+
558
+ const sids = parseAllCookieValues(req.headers?.cookie, 'sid').filter((s) => s && s !== 'logged_out');
559
+ for (const token of sids) {
560
+ const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
561
+ if (sess) return getUserById(1);
562
+ }
563
+
564
+ // На локальном компьютере (127.0.0.1) после активации терминал открывается сразу без пароля
565
+ const explicitlyLoggedOut = parseAllCookieValues(req.headers?.cookie, 'sid').includes('logged_out');
566
+ if (!explicitlyLoggedOut && isLoopbackRequest(req)) {
567
+ return getUserById(1);
568
+ }
569
+ return null;
570
+ }
571
+
572
+ export function authMiddleware(req, res, next) {
573
+ const user = userFromRequest(req);
574
+ req.user = user;
575
+ runWithUser(user, () => next());
576
+ }