neuroalpha 0.6.13 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +471 -166
- package/public/index.html +9 -39
- package/public/styles.css +42 -3
- package/server/api/routes.js +595 -906
- package/server/auth/index.js +583 -761
- package/server/brokers/index.js +14 -3
- package/server/config.js +73 -53
- package/server/index.js +186 -212
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/model/live.js +2 -1
- package/server/notify/index.js +304 -466
- package/server/strategist/index.js +4 -1
- 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,583 @@
|
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
return
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
export function
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
export function
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
export function
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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 в DATA_DIR/.master.key);
|
|
4
|
+
// • НИКАКИХ dev-обходов: без привязанного токена и действующей подписанной лицензии (Ed25519) торговля и сигналы отключены;
|
|
5
|
+
// • на локальном ПК (127.0.0.1) открывается интерфейс терминала с верхней красной панелью error и переходом в раздел «Лицензия»;
|
|
6
|
+
// • по внешней сети / на VPS вход в терминал закрыт токеном ЛК или локальным паролем.
|
|
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, 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
|
+
export function getTerminalLicenseState() {
|
|
216
|
+
const stored = kvGet('terminalLicense') || null;
|
|
217
|
+
const localPassHash = kvGet('terminalLocalPassHash') || '';
|
|
218
|
+
if (!stored?.licenseToken || !stored?.publicKeyPem) {
|
|
219
|
+
return {
|
|
220
|
+
activated: false,
|
|
221
|
+
valid: false,
|
|
222
|
+
expired: false,
|
|
223
|
+
plan: 'none',
|
|
224
|
+
planName: PLANS.none.name,
|
|
225
|
+
validUntil: 0,
|
|
226
|
+
perpetual: false,
|
|
227
|
+
email: '',
|
|
228
|
+
name: '',
|
|
229
|
+
licId: '',
|
|
230
|
+
label: '',
|
|
231
|
+
portalUrl: stored?.portalUrl || DEFAULT_CLOUD_URL,
|
|
232
|
+
hasCabinetToken: Boolean(stored?.cabinetTokenEnc),
|
|
233
|
+
hasLocalPassword: Boolean(localPassHash),
|
|
234
|
+
installId: getInstallId(),
|
|
235
|
+
slots: Array.isArray(stored?.allSlots) ? stored.allSlots : [],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const p = verifyLicenseCertificate(stored.licenseToken, stored.publicKeyPem, getInstallId());
|
|
241
|
+
const validUntil = Number(p.validUntil) || 0;
|
|
242
|
+
const perpetual = Boolean(p.perpetual || validUntil === 0);
|
|
243
|
+
const expired = !perpetual && validUntil <= Date.now();
|
|
244
|
+
const planId = !expired && PLANS[p.plan] ? p.plan : 'none';
|
|
245
|
+
return {
|
|
246
|
+
activated: true,
|
|
247
|
+
valid: !expired && planId !== 'none',
|
|
248
|
+
expired,
|
|
249
|
+
plan: planId,
|
|
250
|
+
planName: PLANS[planId]?.name || p.planName || planId,
|
|
251
|
+
rawPlanName: p.planName || PLANS[p.plan]?.name || p.plan,
|
|
252
|
+
validUntil,
|
|
253
|
+
perpetual,
|
|
254
|
+
email: p.email || '',
|
|
255
|
+
name: p.name || '',
|
|
256
|
+
licId: p.licId || stored.slotId || '',
|
|
257
|
+
label: p.label || '',
|
|
258
|
+
portalUrl: stored.portalUrl || p.portalUrl || DEFAULT_CLOUD_URL,
|
|
259
|
+
hasCabinetToken: Boolean(stored.cabinetTokenEnc),
|
|
260
|
+
hasLocalPassword: Boolean(localPassHash),
|
|
261
|
+
installId: getInstallId(),
|
|
262
|
+
lastFetchedAt: stored.fetchedAt || 0,
|
|
263
|
+
slots: Array.isArray(stored.allSlots) ? stored.allSlots : [],
|
|
264
|
+
};
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return {
|
|
267
|
+
activated: false,
|
|
268
|
+
valid: false,
|
|
269
|
+
expired: false,
|
|
270
|
+
error: err.message,
|
|
271
|
+
plan: 'none',
|
|
272
|
+
planName: PLANS.none.name,
|
|
273
|
+
validUntil: 0,
|
|
274
|
+
perpetual: false,
|
|
275
|
+
email: '',
|
|
276
|
+
name: '',
|
|
277
|
+
licId: '',
|
|
278
|
+
label: '',
|
|
279
|
+
portalUrl: stored.portalUrl || DEFAULT_CLOUD_URL,
|
|
280
|
+
hasCabinetToken: Boolean(stored.cabinetTokenEnc),
|
|
281
|
+
hasLocalPassword: Boolean(localPassHash),
|
|
282
|
+
installId: getInstallId(),
|
|
283
|
+
slots: Array.isArray(stored.allSlots) ? stored.allSlots : [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function getStoredCabinetCredentials() {
|
|
289
|
+
const stored = kvGet('terminalLicense') || {};
|
|
290
|
+
const token = decryptSecret(stored.cabinetTokenEnc || '');
|
|
291
|
+
const portalUrl = String(stored.portalUrl || DEFAULT_CLOUD_URL).replace(/\/+$/, '');
|
|
292
|
+
return { token, portalUrl };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function detachTerminalLicense() {
|
|
296
|
+
const stored = kvGet('terminalLicense') || {};
|
|
297
|
+
kvSet('terminalLicense', { portalUrl: stored.portalUrl || DEFAULT_CLOUD_URL });
|
|
298
|
+
kvSet('cloudLicense', null);
|
|
299
|
+
db.prepare("UPDATE users SET plan = 'none', plan_until = 0 WHERE id = 1").run();
|
|
300
|
+
return getTerminalLicenseState();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Скачивание подписанной автономной лицензии (и полного списка лицензий кабинета) по токену ЛК (na_tok_...)
|
|
304
|
+
export async function activateTerminalByToken({ token = '', portalUrl = '', slotId = '' } = {}) {
|
|
305
|
+
const stored = kvGet('terminalLicense') || {};
|
|
306
|
+
const cleanToken = String(token || decryptSecret(stored.cabinetTokenEnc || '')).trim();
|
|
307
|
+
if (!cleanToken || !cleanToken.startsWith('na_tok_')) {
|
|
308
|
+
throw new Error('Введите корректный токен из личного кабинета (начинается с na_tok_…)');
|
|
309
|
+
}
|
|
310
|
+
const basePortal = String(
|
|
311
|
+
(IS_PACKAGED ? BUILD.centralUrl : (portalUrl || stored.portalUrl || DEFAULT_CLOUD_URL)),
|
|
312
|
+
).trim().replace(/\/+$/, '');
|
|
313
|
+
if (!/^https?:\/\//i.test(basePortal)) {
|
|
314
|
+
throw new Error('Укажите корректный адрес портала лицензий (https://…)');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const r = await fetch(`${basePortal}/api/license/fetch`, {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: {
|
|
320
|
+
'Content-Type': 'application/json',
|
|
321
|
+
Authorization: `Bearer ${cleanToken}`,
|
|
322
|
+
},
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
token: cleanToken,
|
|
325
|
+
installId: getInstallId(),
|
|
326
|
+
hostname: os.hostname().slice(0, 64),
|
|
327
|
+
slotId: String(slotId || '').trim(),
|
|
328
|
+
}),
|
|
329
|
+
signal: AbortSignal.timeout(20000),
|
|
330
|
+
});
|
|
331
|
+
const j = await r.json().catch(() => ({}));
|
|
332
|
+
if (!r.ok) {
|
|
333
|
+
throw new Error(j.error || `Портал вернул ошибку HTTP ${r.status}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (j.chooseSlot) {
|
|
337
|
+
return {
|
|
338
|
+
chooseSlot: true,
|
|
339
|
+
portalUrl: basePortal,
|
|
340
|
+
email: j.email || '',
|
|
341
|
+
name: j.name || '',
|
|
342
|
+
slots: j.slots || [],
|
|
343
|
+
allSlots: j.allSlots || j.slots || [],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!j.licenseToken || !j.publicKeyPem) {
|
|
348
|
+
throw new Error('Портал не вернул подписанный сертификат лицензии');
|
|
349
|
+
}
|
|
350
|
+
const verified = verifyLicenseCertificate(j.licenseToken, j.publicKeyPem, getInstallId());
|
|
351
|
+
const allSlots = Array.isArray(j.allSlots) ? j.allSlots : (j.slot ? [j.slot] : []);
|
|
352
|
+
|
|
353
|
+
kvSet('terminalLicense', {
|
|
354
|
+
portalUrl: basePortal,
|
|
355
|
+
publicKeyPem: j.publicKeyPem,
|
|
356
|
+
cabinetTokenEnc: encryptSecret(cleanToken),
|
|
357
|
+
cabinetTokenHash: sha256Hex(cleanToken),
|
|
358
|
+
slotId: verified.licId || j.slot?.id || '',
|
|
359
|
+
licenseToken: j.licenseToken,
|
|
360
|
+
allSlots,
|
|
361
|
+
fetchedAt: Date.now(),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
kvSet('cloudLicense', {
|
|
365
|
+
apiKey: cleanToken,
|
|
366
|
+
cloudUrl: basePortal,
|
|
367
|
+
email: verified.email,
|
|
368
|
+
name: verified.name,
|
|
369
|
+
plan: verified.plan,
|
|
370
|
+
planName: verified.planName,
|
|
371
|
+
planUntil: verified.validUntil || 0,
|
|
372
|
+
verifiedAt: Date.now(),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
db.prepare('UPDATE users SET email = ?, name = ?, plan = ?, plan_until = ?, offer_accepted_at = ? WHERE id = 1').run(
|
|
376
|
+
String(verified.email || 'user@neuroalpha.local'),
|
|
377
|
+
String(verified.name || 'Пользователь'),
|
|
378
|
+
String(verified.plan || 'none'),
|
|
379
|
+
Number(verified.validUntil) || 0,
|
|
380
|
+
Date.now(),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
chooseSlot: false,
|
|
385
|
+
state: getTerminalLicenseState(),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function activateLocalLicense(token, portalUrl = '') {
|
|
390
|
+
const out = await activateTerminalByToken({ token, portalUrl });
|
|
391
|
+
return out.state || out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Автономное фоновое продление: НЕ пингует портал вообще, пока до истечения лицензии остаётся > 3 суток!
|
|
395
|
+
let lastAutoRefreshAttemptTs = 0;
|
|
396
|
+
export function startAutonomousLicenseWatcher() {
|
|
397
|
+
const checkIfExpiringSoon = async () => {
|
|
398
|
+
try {
|
|
399
|
+
const st = getTerminalLicenseState();
|
|
400
|
+
if (!st.activated || st.perpetual || !st.hasCabinetToken) return;
|
|
401
|
+
const leftMs = (Number(st.validUntil) || 0) - Date.now();
|
|
402
|
+
if (leftMs > RENEW_WINDOW_MS) return;
|
|
403
|
+
if (Date.now() - lastAutoRefreshAttemptTs < 12 * 3600000) return;
|
|
404
|
+
lastAutoRefreshAttemptTs = Date.now();
|
|
405
|
+
const stored = kvGet('terminalLicense') || {};
|
|
406
|
+
await activateTerminalByToken({ slotId: stored.slotId || '' });
|
|
407
|
+
} catch { /* при офлайне продолжаем работать по текущему сертификату до его конца */ }
|
|
408
|
+
};
|
|
409
|
+
const timer = setInterval(checkIfExpiringSoon, 3600000);
|
|
410
|
+
timer.unref?.();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Строгое правило: БЕЗ ДЕЙСТВУЮЩЕЙ ЛИЦЕНЗИИ — НИКАКОЙ ТОРГОВЛИ (allowNew = false, allowProtective = false)!
|
|
414
|
+
export function tradingGate() {
|
|
415
|
+
const st = getTerminalLicenseState();
|
|
416
|
+
if (!st.activated || !st.valid) {
|
|
417
|
+
return {
|
|
418
|
+
allowNew: false,
|
|
419
|
+
allowProtective: false,
|
|
420
|
+
reason: st.expired
|
|
421
|
+
? 'Срок действия лицензии истёк — торговля и сигналы полностью остановлены. Загрузите продлённую лицензию в разделе «Лицензия».'
|
|
422
|
+
: 'Лицензия не активна — торговля и сигналы отключены. Откройте раздел «Лицензия» и загрузите лицензию по токену личного кабинета.',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return { allowNew: true, allowProtective: true, reason: '' };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------- Защита входа при удалённом доступе (VPS / внешняя сеть) ----------
|
|
429
|
+
const loginFails = new Map();
|
|
430
|
+
function checkRemoteLoginRate(ip) {
|
|
431
|
+
if (!ip) return;
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
const r = loginFails.get(ip);
|
|
434
|
+
if (r && now - r.firstAt < 15 * 60000 && r.count >= 8) {
|
|
435
|
+
const leftMin = Math.ceil((15 * 60000 - (now - r.firstAt)) / 60000);
|
|
436
|
+
throw new Error(`Слишком много неверных попыток входа. Повторите через ${leftMin} мин.`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function recordRemoteFail(ip) {
|
|
441
|
+
if (!ip) return;
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const r = loginFails.get(ip);
|
|
444
|
+
if (!r || now - r.firstAt > 15 * 60000) loginFails.set(ip, { count: 1, firstAt: now });
|
|
445
|
+
else r.count += 1;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export async function loginRemoteTerminal(secretInput, { ip = '' } = {}) {
|
|
449
|
+
checkRemoteLoginRate(ip);
|
|
450
|
+
const clean = String(secretInput || '').trim();
|
|
451
|
+
if (!clean) throw new Error('Введите токен личного кабинета или локальный пароль терминала');
|
|
452
|
+
|
|
453
|
+
const stored = kvGet('terminalLicense') || {};
|
|
454
|
+
let ok = false;
|
|
455
|
+
if (stored.cabinetTokenHash && clean.startsWith('na_tok_')) {
|
|
456
|
+
const a = Buffer.from(stored.cabinetTokenHash, 'hex');
|
|
457
|
+
const b = Buffer.from(sha256Hex(clean), 'hex');
|
|
458
|
+
ok = a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
459
|
+
}
|
|
460
|
+
const localPassHash = kvGet('terminalLocalPassHash') || '';
|
|
461
|
+
if (!ok && localPassHash) {
|
|
462
|
+
ok = await verifyPassword(clean, localPassHash);
|
|
463
|
+
}
|
|
464
|
+
if (!ok) {
|
|
465
|
+
recordRemoteFail(ip);
|
|
466
|
+
throw new Error('Неверный токен или локальный пароль терминала');
|
|
467
|
+
}
|
|
468
|
+
if (ip) loginFails.delete(ip);
|
|
469
|
+
return createSession(1, 30);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function setTerminalLocalPassword(newPassword = '') {
|
|
473
|
+
const clean = String(newPassword || '').trim();
|
|
474
|
+
if (!clean) {
|
|
475
|
+
kvSet('terminalLocalPassHash', '');
|
|
476
|
+
return { hasLocalPassword: false };
|
|
477
|
+
}
|
|
478
|
+
if (clean.length < 6) throw new Error('Локальный пароль должен содержать минимум 6 символов');
|
|
479
|
+
kvSet('terminalLocalPassHash', hashPassword(clean));
|
|
480
|
+
return { hasLocalPassword: true };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------- Текущий пользователь терминала и сессии ----------
|
|
484
|
+
export function getUserById(id) {
|
|
485
|
+
return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function currentUser() {
|
|
489
|
+
const store = ctx.getStore();
|
|
490
|
+
if (store) return store.user || null;
|
|
491
|
+
return getUserById(1);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function currentUserId() {
|
|
495
|
+
return resolveUid();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function runWithUser(user, fn) {
|
|
499
|
+
return ctx.run({ user }, fn);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function effectivePlan() {
|
|
503
|
+
const st = getTerminalLicenseState();
|
|
504
|
+
if (!st.valid) return PLANS.none;
|
|
505
|
+
return PLANS[st.plan] || PLANS.none;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function publicUser(u) {
|
|
509
|
+
if (!u) return null;
|
|
510
|
+
const st = getTerminalLicenseState();
|
|
511
|
+
const plan = effectivePlan();
|
|
512
|
+
const isOwner = Boolean(st.valid && (plan.id === 'owner' || plan.allowResearch));
|
|
513
|
+
return {
|
|
514
|
+
id: 1,
|
|
515
|
+
email: st.valid ? (st.email || '') : '',
|
|
516
|
+
name: st.valid ? (st.name || 'Пользователь') : 'Не активирован',
|
|
517
|
+
role: isOwner ? 'admin' : 'user',
|
|
518
|
+
isOwner,
|
|
519
|
+
licensed: Boolean(st.valid),
|
|
520
|
+
plan: plan.id,
|
|
521
|
+
planName: plan.name,
|
|
522
|
+
planUntil: st.perpetual ? 0 : (st.validUntil || 0),
|
|
523
|
+
perpetual: Boolean(st.perpetual),
|
|
524
|
+
limits: plan,
|
|
525
|
+
license: st,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function isLoopbackRequest(req) {
|
|
530
|
+
if (process.env.LOCAL_TRUST_LAN === '1') return true;
|
|
531
|
+
if (req?.headers?.['x-forwarded-for'] || req?.headers?.['x-real-ip'] || req?.headers?.forwarded) return false;
|
|
532
|
+
const addr = String(req?.socket?.remoteAddress || '');
|
|
533
|
+
if (!(addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1')) return false;
|
|
534
|
+
const host = String(req?.headers?.host || '').toLowerCase().replace(/:\d+$/, '');
|
|
535
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function parseAllCookieValues(cookieHeader = '', key = 'sid') {
|
|
539
|
+
const vals = [];
|
|
540
|
+
for (const part of String(cookieHeader || '').split(';')) {
|
|
541
|
+
const i = part.indexOf('=');
|
|
542
|
+
if (i > 0 && part.slice(0, i).trim() === key) {
|
|
543
|
+
vals.push(decodeURIComponent(part.slice(i + 1).trim()));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return vals;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function createSession(userId = 1, days = 30) {
|
|
550
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
db.prepare('INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)')
|
|
553
|
+
.run(token, Number(userId), now, now + days * DAY_MS);
|
|
554
|
+
return token;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function deleteSession(token) {
|
|
558
|
+
if (!token) return;
|
|
559
|
+
db.prepare('DELETE FROM sessions WHERE token = ?').run(String(token));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function userFromRequest(req) {
|
|
563
|
+
// 1) По активной cookie-сессии (для VPS и удалённого доступа)
|
|
564
|
+
const sids = parseAllCookieValues(req.headers?.cookie, 'sid').filter((s) => s && s !== 'logged_out');
|
|
565
|
+
for (const token of sids) {
|
|
566
|
+
const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
|
|
567
|
+
if (sess) return getUserById(1);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 2) На локальном компьютере (localhost / 127.0.0.1) открываем сам интерфейс терминала сразу
|
|
571
|
+
// (при отсутствии лицензии вверху горит красная плашка error, а все торговые контуры закрыты)
|
|
572
|
+
const explicitlyLoggedOut = parseAllCookieValues(req.headers?.cookie, 'sid').includes('logged_out');
|
|
573
|
+
if (!explicitlyLoggedOut && isLoopbackRequest(req)) {
|
|
574
|
+
return getUserById(1);
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function authMiddleware(req, res, next) {
|
|
580
|
+
const user = userFromRequest(req);
|
|
581
|
+
req.user = user;
|
|
582
|
+
runWithUser(user, () => next());
|
|
583
|
+
}
|