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.
- package/README.md +14 -10
- package/bin/cli.js +79 -69
- 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
- package/package.json +4 -5
- package/public/activate.html +41 -19
- package/public/activate.js +81 -10
- package/public/app.js +300 -152
- package/public/index.html +4 -37
- package/server/api/routes.js +570 -906
- package/server/auth/index.js +576 -761
- package/server/brokers/index.js +1 -4
- package/server/config.js +73 -53
- package/server/index.js +186 -187
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/model/dataset.js +2 -4
- package/server/model/forecast.js +1 -3
- package/server/news/index.js +2 -3
- package/server/notify/index.js +304 -466
- package/deploy/docs/01-central.md +0 -96
- package/deploy/docs/02-cloud-instances.md +0 -130
- package/deploy/docs/03-desktop-build.md +0 -92
- package/deploy/docs/04-client-guide.md +0 -101
- package/deploy/docs/05-operations.md +0 -109
- package/deploy/docs/06-env-reference.md +0 -91
- package/deploy/docs/07-stress-test.md +0 -253
- package/deploy/docs/pdf/01-central.pdf +0 -0
- package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
- package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
- package/deploy/docs/pdf/04-client-guide.pdf +0 -0
- package/deploy/docs/pdf/05-operations.pdf +0 -0
- package/deploy/docs/pdf/06-env-reference.pdf +0 -0
- package/deploy/docs/pdf/07-stress-test.pdf +0 -0
- package/deploy/install.sh +0 -128
- package/deploy/k8s/instance.yaml +0 -147
- package/public/admin.html +0 -604
- package/public/landing.html +0 -1207
- package/public/legal.html +0 -134
- package/scripts/set-admin-password.mjs +0 -30
- package/server/billing/yookassa.js +0 -142
- package/server/instance/agent.js +0 -259
- package/server/instance/central.js +0 -514
- package/server/instance/identity.js +0 -79
- package/server/instance/provision.js +0 -166
- package/server/instance/signing.js +0 -71
- package/server/role.js +0 -44
package/server/auth/index.js
CHANGED
|
@@ -1,761 +1,576 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (!
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
return
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
export function
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
}
|