neuroalpha 0.7.0 → 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/package.json +1 -1
- package/public/app.js +213 -56
- package/public/index.html +7 -4
- package/public/styles.css +42 -3
- package/server/api/routes.js +45 -20
- package/server/auth/index.js +46 -39
- package/server/brokers/index.js +13 -1
- package/server/index.js +3 -3
- package/server/model/live.js +2 -1
- package/server/strategist/index.js +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "НейроАльфа — автономный торговый терминал для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор, активация токеном ЛК и автоустановка локальной ИИ-модели Стратега",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -190,12 +190,51 @@ $('#chat-hide')?.addEventListener('click', () => setChatClosed(true));
|
|
|
190
190
|
// ======================================================================
|
|
191
191
|
// НАВИГАЦИЯ В ЕДИНОМ РАБОЧЕМ ОКНЕ
|
|
192
192
|
// ======================================================================
|
|
193
|
+
function isLicensed() {
|
|
194
|
+
const u = state.config?.user;
|
|
195
|
+
const lic = state.config?.license || u?.license;
|
|
196
|
+
return Boolean(u?.licensed || lic?.valid);
|
|
197
|
+
}
|
|
198
|
+
|
|
193
199
|
function isOwnerUser() {
|
|
200
|
+
if (!isLicensed()) return false;
|
|
194
201
|
const u = state.config?.user;
|
|
195
202
|
return Boolean(u?.isOwner || u?.role === 'admin' || u?.plan === 'owner' || u?.limits?.allowResearch);
|
|
196
203
|
}
|
|
197
204
|
|
|
205
|
+
function syncLicenseTopBar() {
|
|
206
|
+
const licensed = isLicensed();
|
|
207
|
+
const u = state.config?.user;
|
|
208
|
+
const lic = state.config?.license || u?.license || {};
|
|
209
|
+
const errBar = $('#lic-error-bar');
|
|
210
|
+
const errTxt = $('#lic-error-text');
|
|
211
|
+
const userBtn = $('#btn-user');
|
|
212
|
+
|
|
213
|
+
if (errBar) {
|
|
214
|
+
errBar.hidden = licensed;
|
|
215
|
+
if (!licensed && errTxt) {
|
|
216
|
+
errTxt.textContent = lic.expired
|
|
217
|
+
? 'СРОК ЛИЦЕНЗИИ ИСТЁК (Торговля и сигналы отключены)'
|
|
218
|
+
: 'ЛИЦЕНЗИЯ НЕ УСТАНОВЛЕНА (Торговля и сигналы отключены)';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (userBtn) {
|
|
222
|
+
if (licensed) {
|
|
223
|
+
const dispName = lic.name || u?.name || 'Лицензиат';
|
|
224
|
+
userBtn.textContent = `${dispName} · ${u?.planName || lic.planName || ''}`;
|
|
225
|
+
userBtn.classList.remove('lic-err');
|
|
226
|
+
} else {
|
|
227
|
+
userBtn.textContent = '⚠ Получить лицензию';
|
|
228
|
+
userBtn.classList.add('lic-err');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if ($('#btn-broker-add')) $('#btn-broker-add').disabled = !licensed;
|
|
232
|
+
if ($('#btn-broker-stop-all')) $('#btn-broker-stop-all').disabled = !licensed;
|
|
233
|
+
if ($('#btn-refresh-forecast')) $('#btn-refresh-forecast').disabled = !licensed;
|
|
234
|
+
}
|
|
235
|
+
|
|
198
236
|
function updateTabBadges() {
|
|
237
|
+
syncLicenseTopBar();
|
|
199
238
|
const ovNav = $('#nnav-overview');
|
|
200
239
|
if (ovNav) {
|
|
201
240
|
const owner = isOwnerUser();
|
|
@@ -994,12 +1033,24 @@ function triggerTradingPulse(text = 'Обновление котировок и
|
|
|
994
1033
|
}
|
|
995
1034
|
|
|
996
1035
|
async function loadForecast(force, silent = false) {
|
|
1036
|
+
const box = $('#forecast');
|
|
1037
|
+
if (!isLicensed()) {
|
|
1038
|
+
state.forecast = null;
|
|
1039
|
+
if (box) {
|
|
1040
|
+
box.innerHTML = `<div class="live-card" style="border-color:#ef4444;padding:16px 18px">
|
|
1041
|
+
<div style="font-weight:700;color:#f87171;margin-bottom:6px">🔒 Живой радар прогнозов отключён: нет активной лицензии</div>
|
|
1042
|
+
<div class="muted small" style="margin-bottom:12px">Для расчёта сигналов Мосбиржи и сопровождения сделок перейдите в раздел «Лицензия», получите токен на сайте в личном кабинете и загрузите оплаченную лицензию.</div>
|
|
1043
|
+
<button type="button" class="primary" data-goto-license>Получить лицензию →</button>
|
|
1044
|
+
</div>`;
|
|
1045
|
+
}
|
|
1046
|
+
if ($('#fc-live-badge')) $('#fc-live-badge').textContent = '⛔ нет лицензии';
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
997
1049
|
if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
|
|
998
1050
|
forecastBusy = true;
|
|
999
1051
|
forecastBusyAt = Date.now();
|
|
1000
1052
|
syncUiAvailability();
|
|
1001
1053
|
triggerTradingPulse(force ? 'Пересчёт матрицы внимания…' : 'Сверка живых котировок Мосбиржи…', 1800);
|
|
1002
|
-
const box = $('#forecast');
|
|
1003
1054
|
if (!silent && !state.forecast) {
|
|
1004
1055
|
box.innerHTML = `<div class="ld-calc-banner" style="margin:4px 0">
|
|
1005
1056
|
<div class="ld-calc-spin"></div>
|
|
@@ -1248,6 +1299,10 @@ async function loadForecast(force, silent = false) {
|
|
|
1248
1299
|
const FORECAST_TICK_MS = 15000; // живое обновление каждые 15 секунд
|
|
1249
1300
|
let lastForecastReqAt = 0;
|
|
1250
1301
|
function refreshForecastIfStale() {
|
|
1302
|
+
if (!isLicensed()) {
|
|
1303
|
+
loadForecast(false, true);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1251
1306
|
if (forecastBusy && Date.now() - forecastBusyAt < 25000) return;
|
|
1252
1307
|
if (!state.forecast) {
|
|
1253
1308
|
lastForecastReqAt = Date.now();
|
|
@@ -2592,7 +2647,7 @@ let brokerPollBusy = false;
|
|
|
2592
2647
|
let lastBrokerPollTs = 0;
|
|
2593
2648
|
|
|
2594
2649
|
async function pollActiveBrokerAccount(force = false) {
|
|
2595
|
-
if (brokerPollBusy || document.hidden || editingBrokerId) return;
|
|
2650
|
+
if (!isLicensed() || brokerPollBusy || document.hidden || editingBrokerId) return;
|
|
2596
2651
|
const brokersSecHidden = $('#brokers-trade-box')?.hidden;
|
|
2597
2652
|
if (brokersSecHidden) return;
|
|
2598
2653
|
const accs = state.brokers?.accounts || [];
|
|
@@ -2642,6 +2697,15 @@ function renderBrokers() {
|
|
|
2642
2697
|
|
|
2643
2698
|
const tradeEl = $('#brokers-trade');
|
|
2644
2699
|
if (!tradeEl) return;
|
|
2700
|
+
if (!isLicensed() || state.brokers?.unlicensed) {
|
|
2701
|
+
if (formBox) formBox.hidden = true;
|
|
2702
|
+
tradeEl.innerHTML = `<div class="live-card" style="border-color:#ef4444;padding:16px 18px">
|
|
2703
|
+
<div style="font-weight:700;color:#f87171;margin-bottom:6px">🔒 Торговля полностью заблокирована: нет активной лицензии</div>
|
|
2704
|
+
<div class="muted small" style="margin-bottom:12px">Без действующей лицензии подключение счетов, расчёт и отправка заявок брокеру отключены. Откройте раздел «Лицензия», перейдите на сайт в личный кабинет и загрузите лицензию по вашему токену.</div>
|
|
2705
|
+
<button type="button" class="primary" data-goto-license>Получить лицензию →</button>
|
|
2706
|
+
</div>`;
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2645
2709
|
if (!accs.length) {
|
|
2646
2710
|
tradeEl.innerHTML = 'Нажмите кнопку <b>«+ Добавить счёт»</b> выше, чтобы подключить ваш брокерский счёт (Т-Банк, Финам, БКС, Алор, Цифра брокер, ITI Capital, СберИнвестиции, ВТБ, Альфа-Инвестиции).';
|
|
2647
2711
|
return;
|
|
@@ -3783,6 +3847,16 @@ function connectWs() {
|
|
|
3783
3847
|
}
|
|
3784
3848
|
break;
|
|
3785
3849
|
}
|
|
3850
|
+
case 'license:changed': {
|
|
3851
|
+
api('/api/config').then((cfg) => {
|
|
3852
|
+
state.config = { ...(state.config || {}), ...cfg };
|
|
3853
|
+
updateTabBadges();
|
|
3854
|
+
loadBrokers().catch(() => {});
|
|
3855
|
+
if (isLicensed()) refreshForecastIfStale();
|
|
3856
|
+
else loadForecast(false, true);
|
|
3857
|
+
}).catch(() => {});
|
|
3858
|
+
break;
|
|
3859
|
+
}
|
|
3786
3860
|
case 'log': toast(m.text, m.level === 'error' ? 'error' : 'info'); break;
|
|
3787
3861
|
}
|
|
3788
3862
|
};
|
|
@@ -3953,10 +4027,17 @@ function renderLocalLlmBox() {
|
|
|
3953
4027
|
}
|
|
3954
4028
|
|
|
3955
4029
|
// ---------- Автономная лицензия терминала, защита VPS и шлюз уведомлений Telegram / MAX ----------
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
4030
|
+
function openLicenseSection() {
|
|
4031
|
+
showMainSec('cloud');
|
|
4032
|
+
renderCloudPane().then(() => {
|
|
4033
|
+
$('#lic-token-in')?.focus();
|
|
4034
|
+
}).catch(() => {});
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
$('#btn-user')?.addEventListener('click', openLicenseSection);
|
|
4038
|
+
$('#btn-get-license')?.addEventListener('click', openLicenseSection);
|
|
4039
|
+
document.addEventListener('click', (e) => {
|
|
4040
|
+
if (e.target.closest('[data-goto-license]')) openLicenseSection();
|
|
3960
4041
|
});
|
|
3961
4042
|
|
|
3962
4043
|
async function renderCloudPane() {
|
|
@@ -3967,69 +4048,104 @@ async function renderCloudPane() {
|
|
|
3967
4048
|
const u = me.user;
|
|
3968
4049
|
const lic = me.license || u?.license || {};
|
|
3969
4050
|
const nt = me.notify || {};
|
|
3970
|
-
|
|
3971
|
-
const dispName = lic.name || u?.name || 'Пользователь';
|
|
3972
|
-
const userBtn = $('#btn-user');
|
|
3973
|
-
if (userBtn) userBtn.textContent = u ? `${dispName} · ${u.planName}` : 'Лицензия';
|
|
3974
4051
|
updateTabBadges();
|
|
3975
4052
|
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
4053
|
+
const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
|
|
4054
|
+
const slots = Array.isArray(lic.slots) ? lic.slots : [];
|
|
4055
|
+
|
|
4056
|
+
const statusHtml = lic.valid
|
|
4057
|
+
? `<span class="tag ok">● Активна: ${esc(u?.planName || lic.planName)}</span>`
|
|
4058
|
+
: `<span class="tag no">⛔ ${lic.expired ? 'Срок действия истёк' : 'Лицензия не установлена'} — торговля отключена</span>`;
|
|
3980
4059
|
|
|
3981
|
-
const validStr = lic.
|
|
3982
|
-
?
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
const capLimStr = u.limits?.maxCapitalPerAccountRub >= 100000000
|
|
4060
|
+
const validStr = lic.valid
|
|
4061
|
+
? (lic.perpetual
|
|
4062
|
+
? 'действует <b>бессрочно</b> (100% автономно без запросов к сайту)'
|
|
4063
|
+
: `действует автономно до <b>${fmtDate(lic.validUntil)}</b>`)
|
|
4064
|
+
: 'привяжите токен из личного кабинета и загрузите оплаченные лицензии ниже';
|
|
4065
|
+
|
|
4066
|
+
const capLimStr = u?.limits?.maxCapitalPerAccountRub >= 100000000
|
|
3989
4067
|
? 'без ограничений'
|
|
3990
|
-
: u
|
|
4068
|
+
: u?.limits?.maxCapitalPerAccountRub > 0
|
|
3991
4069
|
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ вся прибыль)`
|
|
3992
|
-
: '
|
|
3993
|
-
const dirLimStr =
|
|
3994
|
-
|
|
4070
|
+
: '—';
|
|
4071
|
+
const dirLimStr = lic.valid
|
|
4072
|
+
? (u?.limits?.allowShorts ? 'покупки и шорты (Long / Short)' : 'только покупки (Long)')
|
|
4073
|
+
: 'торговля отключена';
|
|
4074
|
+
|
|
4075
|
+
const slotsTableHtml = slots.length
|
|
4076
|
+
? `<div style="margin-top:10px;overflow-x:auto">
|
|
4077
|
+
<table style="width:100%;font-size:13px">
|
|
4078
|
+
<thead><tr><th>Лицензия</th><th>Тариф</th><th>Статус и срок (1 мес. = 31 сутки)</th><th>Устройство</th></tr></thead>
|
|
4079
|
+
<tbody>${slots.map((s) => {
|
|
4080
|
+
const isThisPc = s.id === lic.licId;
|
|
4081
|
+
const stBadge = s.revoked
|
|
4082
|
+
? '<span class="tag no">отозвана</span>'
|
|
4083
|
+
: s.perpetual
|
|
4084
|
+
? '<span class="tag ok">бессрочно</span>'
|
|
4085
|
+
: s.activated
|
|
4086
|
+
? `<span class="tag ok">активна до ${fmtDate(s.validUntil)}</span>`
|
|
4087
|
+
: `<span class="tag mid">в очереди (${s.totalRemainingDays || 0} сут.)</span>`;
|
|
4088
|
+
const grantsStr = (s.grants || []).filter((g) => g.status !== 'expired').map((g) => `${esc(g.planName)} (${g.durationDays === 0 ? '∞' : `${g.remainingDays ?? g.durationDays} сут.`})`).join(' → ');
|
|
4089
|
+
return `<tr${isThisPc ? ' style="background:var(--accent-bg)"' : ''}>
|
|
4090
|
+
<td><b>${esc(s.label || s.id)}</b> ${isThisPc ? '<span class="tag ok">этот терминал</span>' : ''}</td>
|
|
4091
|
+
<td><b>${esc(s.planName)}</b></td>
|
|
4092
|
+
<td>${stBadge}${grantsStr ? `<div class="muted small">${grantsStr}</div>` : ''}</td>
|
|
4093
|
+
<td class="small">${s.bound ? `💻 ${esc(s.hostname || 'ПК')}` : '<span class="muted">свободна</span>'}</td>
|
|
4094
|
+
</tr>`;
|
|
4095
|
+
}).join('')}</tbody>
|
|
4096
|
+
</table>
|
|
4097
|
+
</div>`
|
|
4098
|
+
: '<div class="muted small" style="margin-top:6px">Загруженных лицензий пока нет.</div>';
|
|
3995
4099
|
|
|
3996
4100
|
box.innerHTML = `
|
|
3997
|
-
<div class="srow sblock">
|
|
4101
|
+
<div class="srow sblock" style="${!lic.valid ? 'border:1px solid #ef4444;border-radius:10px;padding:14px 16px;background:rgba(211,47,47,0.08)' : ''}">
|
|
3998
4102
|
<div class="stxt">
|
|
3999
|
-
<div class="slbl">
|
|
4000
|
-
Лицензия терминала: <span class="tag ${lic.valid ? 'ok' : 'no'}" style="margin-left:6px">${esc(u.planName)}</span>
|
|
4001
|
-
${lic.label ? `<span class="muted small" style="margin-left:6px">(${esc(lic.label)})</span>` : ''}
|
|
4002
|
-
</div>
|
|
4103
|
+
<div class="slbl">Состояние лицензии терминала: ${statusHtml}</div>
|
|
4003
4104
|
<div class="hint">
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
Сертификат подписан ключом портала (Ed25519) и работает полностью автономно без обращений к сайту до последних 3 суток перед окончанием срока.
|
|
4105
|
+
Срок: ${validStr}${lic.email ? ` · Аккаунт: <b>${esc(lic.email)}</b>` : ''}<br>
|
|
4106
|
+
${lic.valid ? `Квота счетов: до <b>${u?.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим: <b>${dirLimStr}</b>.` : 'Без действующей лицензии любые торговые операции и живой радар сигналов заблокированы.'}
|
|
4007
4107
|
</div>
|
|
4008
4108
|
</div>
|
|
4009
4109
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
4010
|
-
${lic.hasCabinetToken ? '<button type="button" class="primary" id="btn-lic-refresh">↻ Обновить
|
|
4011
|
-
<
|
|
4110
|
+
${lic.hasCabinetToken ? '<button type="button" class="primary" id="btn-lic-refresh">↻ Обновить лицензии с сайта</button>' : ''}
|
|
4111
|
+
${lic.hasCabinetToken ? '<button type="button" id="btn-lic-detach" style="color:var(--red)">Отключить токен</button>' : ''}
|
|
4012
4112
|
${!me.isLoopback ? '<button type="button" id="btn-term-logout" style="color:var(--red)">Выйти из сессии</button>' : ''}
|
|
4013
4113
|
</div>
|
|
4014
4114
|
</div>
|
|
4015
4115
|
|
|
4016
4116
|
<div class="srow sblock">
|
|
4017
4117
|
<div class="stxt">
|
|
4018
|
-
<div class="slbl"
|
|
4019
|
-
<div class="hint"
|
|
4118
|
+
<div class="slbl">Шаг 1. Перейдите на сайт портала и получите токен личного кабинета</div>
|
|
4119
|
+
<div class="hint">Войдите в профиль на сайте по <b>Email + одноразовому коду</b>, оформите нужную лицензию и скопируйте ваш персональный токен <code>na_tok_…</code>.</div>
|
|
4120
|
+
</div>
|
|
4121
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
4122
|
+
<input id="lic-portal-url" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" style="width:250px" title="Адрес портала лицензий">
|
|
4123
|
+
<a id="btn-open-portal" class="btn primary" href="${esc(portalUrl)}" target="_blank" rel="noopener">🌐 Открыть сайт / Личный кабинет →</a>
|
|
4124
|
+
</div>
|
|
4125
|
+
</div>
|
|
4126
|
+
|
|
4127
|
+
<div class="srow sblock">
|
|
4128
|
+
<div class="stxt">
|
|
4129
|
+
<div class="slbl">Шаг 2. Привяжите токен и загрузите оплаченные лицензии</div>
|
|
4130
|
+
<div class="hint">Вставьте скопированный из личного кабинета токен <code>na_tok_…</code> — терминал скачает подписанные криптоключом лицензии и включит работу автономно на весь оплаченный срок.</div>
|
|
4131
|
+
<div id="lic-slot-choice" hidden style="margin-top:10px;display:grid;gap:6px"></div>
|
|
4020
4132
|
</div>
|
|
4021
4133
|
<form id="form-lic-bind" class="row-gap" style="flex-wrap:wrap">
|
|
4022
|
-
<input name="token" placeholder="na_tok_…" style="width:
|
|
4023
|
-
<
|
|
4024
|
-
<button type="submit" class="primary">Скачать лицензию</button>
|
|
4134
|
+
<input id="lic-token-in" name="token" placeholder="na_tok_…" style="width:300px;font-family:monospace" required spellcheck="false">
|
|
4135
|
+
<button type="submit" class="primary" id="btn-lic-download">⬇ Загрузить лицензии</button>
|
|
4025
4136
|
</form>
|
|
4026
4137
|
</div>
|
|
4027
4138
|
|
|
4139
|
+
<div class="srow sblock" style="display:block">
|
|
4140
|
+
<div class="slbl">Загруженные лицензии и очередь пакетов</div>
|
|
4141
|
+
${slotsTableHtml}
|
|
4142
|
+
</div>
|
|
4143
|
+
|
|
4028
4144
|
<div class="srow sblock">
|
|
4029
4145
|
<div class="stxt">
|
|
4030
4146
|
<div class="slbl">Защита удалённого доступа (для работы на арендованной виртуалке / VPS)</div>
|
|
4031
4147
|
<div class="hint">
|
|
4032
|
-
|
|
4148
|
+
На локальном ПК (<code>localhost</code>) интерфейс открыт сразу. При обращении по внешнему IP сервер требует ваш токен <code>na_tok_…</code> либо дополнительный локальный пароль (${lic.hasLocalPassword ? '<b class="pos">установлен ✓</b>' : 'не задан'}).
|
|
4033
4149
|
</div>
|
|
4034
4150
|
</div>
|
|
4035
4151
|
<form id="form-local-pass" class="row-gap" style="flex-wrap:wrap">
|
|
@@ -4042,7 +4158,7 @@ async function renderCloudPane() {
|
|
|
4042
4158
|
<div class="stxt">
|
|
4043
4159
|
<div class="slbl">Уведомления и общение со Стратегом в Telegram и MAX</div>
|
|
4044
4160
|
<div class="hint">
|
|
4045
|
-
По умолчанию уведомления и кнопки подтверждения заявок работают
|
|
4161
|
+
По умолчанию уведомления и кнопки подтверждения заявок работают через ботов портала (привяжите Telegram или MAX в личном кабинете на сайте). При желании ниже можно подключить собственного бота напрямую.
|
|
4046
4162
|
</div>
|
|
4047
4163
|
</div>
|
|
4048
4164
|
<div class="row-gap" style="flex-wrap:wrap">
|
|
@@ -4067,28 +4183,67 @@ async function renderCloudPane() {
|
|
|
4067
4183
|
</details>
|
|
4068
4184
|
`;
|
|
4069
4185
|
|
|
4186
|
+
const portalInput = box.querySelector('#lic-portal-url');
|
|
4187
|
+
const openPortalLink = box.querySelector('#btn-open-portal');
|
|
4188
|
+
portalInput?.addEventListener('input', () => {
|
|
4189
|
+
const v = portalInput.value.trim() || 'https://neuroalpha.odant.org';
|
|
4190
|
+
if (openPortalLink && /^https?:\/\//i.test(v)) openPortalLink.href = v;
|
|
4191
|
+
});
|
|
4192
|
+
|
|
4193
|
+
const applyActivatedState = async () => {
|
|
4194
|
+
state.config = await api('/api/config');
|
|
4195
|
+
updateTabBadges();
|
|
4196
|
+
await renderCloudPane();
|
|
4197
|
+
await loadBrokers().catch(() => {});
|
|
4198
|
+
await loadLlm().catch(() => {});
|
|
4199
|
+
if (isOwnerUser()) await loadRuns().catch(() => {});
|
|
4200
|
+
if (isLicensed()) refreshForecastIfStale();
|
|
4201
|
+
else loadForecast(false, true);
|
|
4202
|
+
};
|
|
4203
|
+
|
|
4070
4204
|
box.querySelector('#btn-lic-refresh')?.addEventListener('click', async () => {
|
|
4071
4205
|
try {
|
|
4072
|
-
const
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
await
|
|
4206
|
+
const pUrl = portalInput?.value.trim() || portalUrl;
|
|
4207
|
+
const r = await post('/api/local/refresh-license', { portalUrl: pUrl });
|
|
4208
|
+
toast(`✓ Лицензии обновлены: ${r.state?.planName || 'активна'}`);
|
|
4209
|
+
await applyActivatedState();
|
|
4210
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
4211
|
+
});
|
|
4212
|
+
|
|
4213
|
+
box.querySelector('#btn-lic-detach')?.addEventListener('click', async () => {
|
|
4214
|
+
if (!confirm('Отключить токен и удалить локальный сертификат лицензии с этого компьютера? Торговля сразу остановится.')) return;
|
|
4215
|
+
try {
|
|
4216
|
+
await post('/api/local/detach-license', {});
|
|
4217
|
+
toast('Лицензия отключена — торговля остановлена');
|
|
4218
|
+
await applyActivatedState();
|
|
4076
4219
|
} catch (e) { toast(e.message, 'error'); }
|
|
4077
4220
|
});
|
|
4078
4221
|
|
|
4079
4222
|
box.querySelector('#form-lic-bind')?.addEventListener('submit', async (e) => {
|
|
4080
4223
|
e.preventDefault();
|
|
4081
4224
|
const f = e.target.elements;
|
|
4225
|
+
const pUrl = portalInput?.value.trim() || portalUrl;
|
|
4226
|
+
const pickedSlot = box.querySelector('input[name="pick_slot_id"]:checked')?.value || '';
|
|
4082
4227
|
try {
|
|
4083
|
-
const r = await post('/api/local/activate', {
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4228
|
+
const r = await post('/api/local/activate', {
|
|
4229
|
+
token: f.token.value.trim(),
|
|
4230
|
+
portalUrl: pUrl,
|
|
4231
|
+
slotId: pickedSlot,
|
|
4232
|
+
});
|
|
4233
|
+
if (r.chooseSlot && Array.isArray(r.slots) && r.slots.length) {
|
|
4234
|
+
const slotBox = box.querySelector('#lic-slot-choice');
|
|
4235
|
+
slotBox.hidden = false;
|
|
4236
|
+
slotBox.innerHTML = `<div class="small" style="font-weight:600">Выберите свободную лицензию для этого терминала и нажмите «Загрузить лицензии» ещё раз:</div>`
|
|
4237
|
+
+ r.slots.map((s, i) => `<label class="row-gap small" style="padding:6px 10px;border:1px solid var(--line);border-radius:7px;cursor:pointer">
|
|
4238
|
+
<input type="radio" name="pick_slot_id" value="${esc(s.id)}" ${i === 0 ? 'checked' : ''}>
|
|
4239
|
+
<span><b>${esc(s.label || s.id)}</b> · ${esc(s.planName)} (${s.perpetual ? 'бессрочно' : `${s.totalRemainingDays || 0} сут.`})</span>
|
|
4240
|
+
</label>`).join('');
|
|
4241
|
+
toast('Выберите нужный слот лицензии в списке');
|
|
4242
|
+
return;
|
|
4087
4243
|
}
|
|
4088
4244
|
f.token.value = '';
|
|
4089
|
-
toast(
|
|
4090
|
-
await
|
|
4091
|
-
await loadLlm();
|
|
4245
|
+
toast(`✓ Лицензия «${r.state?.planName || ''}» успешно загружена и активирована!`);
|
|
4246
|
+
await applyActivatedState();
|
|
4092
4247
|
} catch (err) { toast(err.message, 'error'); }
|
|
4093
4248
|
});
|
|
4094
4249
|
|
|
@@ -4098,7 +4253,7 @@ async function renderCloudPane() {
|
|
|
4098
4253
|
try {
|
|
4099
4254
|
const r = await post('/api/local/password', { password: pw });
|
|
4100
4255
|
e.target.reset();
|
|
4101
|
-
toast(r.hasLocalPassword ? '✓ Локальный пароль для удалённого входа сохранён' : 'Локальный пароль сброшен
|
|
4256
|
+
toast(r.hasLocalPassword ? '✓ Локальный пароль для удалённого входа сохранён' : 'Локальный пароль сброшен');
|
|
4102
4257
|
renderCloudPane();
|
|
4103
4258
|
} catch (err) { toast(err.message, 'error'); }
|
|
4104
4259
|
});
|
|
@@ -4701,7 +4856,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
4701
4856
|
const initRunId = Number(urlParams.get('run')) || Number(savedNav.runId) || undefined;
|
|
4702
4857
|
await loadRuns(initRunId);
|
|
4703
4858
|
}
|
|
4704
|
-
if (
|
|
4859
|
+
if (!isLicensed()) {
|
|
4860
|
+
showMainSec('cloud');
|
|
4861
|
+
} else if (urlParams.get('run') && isOwnerUser()) {
|
|
4705
4862
|
showMainSec('overview');
|
|
4706
4863
|
} else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
|
|
4707
4864
|
showMainSec(savedNav.mainSec);
|
package/public/index.html
CHANGED
|
@@ -11,14 +11,17 @@
|
|
|
11
11
|
<script src="https://unpkg.com/dompurify@3.2.6/dist/purify.min.js" integrity="sha384-JEyTNhjM6R1ElGoJns4U2Ln4ofPcqzSsynQkmEc/KGy6336qAZl70tDLufbkla+3" crossorigin="anonymous"></script>
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
|
+
<div id="lic-error-bar" class="lic-error-bar" hidden>
|
|
15
|
+
<span id="lic-error-text">ЛИЦЕНЗИЯ НЕ УСТАНОВЛЕНА (Торговля и сигналы отключены)</span>
|
|
16
|
+
<button id="btn-get-license" type="button" class="lic-cta-btn">Получить лицензию</button>
|
|
17
|
+
</div>
|
|
14
18
|
<header>
|
|
15
19
|
<div class="brand">
|
|
16
20
|
<button id="btn-chat-toggle" type="button" class="hdr-btn" title="Показать или скрыть панель чата с ИИ-Стратегом">💬 Стратег</button>
|
|
17
21
|
<span class="logo">◈</span><b>НейроАльфа</b><span class="muted">· Рынок как язык · Мосбиржа</span>
|
|
18
22
|
</div>
|
|
19
23
|
<div class="hright">
|
|
20
|
-
<
|
|
21
|
-
<button id="btn-user" type="button" class="hdr-btn" title="Профиль, подписка и боты Telegram / MAX">Профиль</button>
|
|
24
|
+
<button id="btn-user" type="button" class="hdr-btn" title="Лицензия, доступ и уведомления">Лицензия</button>
|
|
22
25
|
<span id="st" class="dot" title="Подключение…"></span>
|
|
23
26
|
<button id="btn-theme" class="hdr-btn icon-only" title="Светлая / тёмная тема">☀</button>
|
|
24
27
|
<button id="btn-help" class="hdr-btn icon-only" title="Справка">?</button>
|
|
@@ -89,7 +92,7 @@
|
|
|
89
92
|
<button type="button" data-sec="news" id="nnav-news">Новости рынка</button>
|
|
90
93
|
<div class="ssep"></div>
|
|
91
94
|
<button type="button" data-sec="overview" id="nnav-overview" hidden>Эксперименты</button>
|
|
92
|
-
<button type="button" data-sec="cloud" id="snav-cloud"
|
|
95
|
+
<button type="button" data-sec="cloud" id="snav-cloud">Лицензия</button>
|
|
93
96
|
<button type="button" data-sec="llm" id="snav-llm">Модели ИИ</button>
|
|
94
97
|
</aside>
|
|
95
98
|
<div class="spane">
|
|
@@ -246,7 +249,7 @@
|
|
|
246
249
|
</section>
|
|
247
250
|
|
|
248
251
|
<section class="ssec" data-sec="cloud" id="cloud-box" hidden>
|
|
249
|
-
<div class="stitle"
|
|
252
|
+
<div class="stitle">Лицензия терминала, доступ и уведомления (Telegram / MAX)</div>
|
|
250
253
|
<div class="scard spadded" id="cloud-pane"></div>
|
|
251
254
|
</section>
|
|
252
255
|
|
package/public/styles.css
CHANGED
|
@@ -47,8 +47,9 @@ body.light {
|
|
|
47
47
|
--hover-bg: #f0f3f6;
|
|
48
48
|
}
|
|
49
49
|
* { box-sizing: border-box; }
|
|
50
|
+
[hidden] { display: none !important; }
|
|
50
51
|
html, body { height: 100%; }
|
|
51
|
-
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overflow: hidden; }
|
|
52
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overflow: hidden; display: flex; flex-direction: column; }
|
|
52
53
|
a, a:visited { color: var(--accent); text-decoration: none; }
|
|
53
54
|
a:hover { color: var(--accent); text-decoration: underline; }
|
|
54
55
|
.muted { color: var(--muted); }
|
|
@@ -58,7 +59,45 @@ a:hover { color: var(--accent); text-decoration: underline; }
|
|
|
58
59
|
.neg { color: var(--red); }
|
|
59
60
|
h4 { margin: 18px 0 6px; font-size: 13px; color: var(--muted); font-weight: 500; }
|
|
60
61
|
|
|
61
|
-
header { height: 48px; display: flex; justify-content: space-between; align-items: center; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
62
|
+
header { height: 48px; flex: none; display: flex; justify-content: space-between; align-items: center; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
63
|
+
.lic-error-bar {
|
|
64
|
+
background: #d32f2f;
|
|
65
|
+
color: #ffffff;
|
|
66
|
+
flex: none;
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: center;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
gap: 12px;
|
|
72
|
+
padding: 5px 16px;
|
|
73
|
+
font-size: 13px;
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
}
|
|
76
|
+
.lic-error-bar button.lic-cta-btn {
|
|
77
|
+
background: #ffffff;
|
|
78
|
+
color: #1e293b;
|
|
79
|
+
border: 0;
|
|
80
|
+
border-radius: 5px;
|
|
81
|
+
padding: 4px 12px;
|
|
82
|
+
font-size: 12.5px;
|
|
83
|
+
font-weight: 700;
|
|
84
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.28);
|
|
85
|
+
}
|
|
86
|
+
.lic-error-bar button.lic-cta-btn:hover {
|
|
87
|
+
background: #f1f5f9;
|
|
88
|
+
color: #0f172a;
|
|
89
|
+
}
|
|
90
|
+
.hdr-btn.lic-err {
|
|
91
|
+
background: rgba(211, 47, 47, 0.16);
|
|
92
|
+
border-color: #ef4444;
|
|
93
|
+
color: #f87171;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
body.light .hdr-btn.lic-err {
|
|
97
|
+
background: #fee2e2;
|
|
98
|
+
border-color: #dc2626;
|
|
99
|
+
color: #b91c1c;
|
|
100
|
+
}
|
|
62
101
|
.brand { display: flex; gap: 8px; align-items: center; }
|
|
63
102
|
.logo { color: var(--accent); font-size: 18px; }
|
|
64
103
|
.hright { display: flex; gap: 8px; align-items: center; }
|
|
@@ -99,7 +138,7 @@ button.icon-lg { width: 40px; height: 40px; padding: 0; font-size: 15px; flex: n
|
|
|
99
138
|
.hdr-btn { padding: 4px 10px; font-size: 12.5px; }
|
|
100
139
|
.hdr-btn.icon-only { width: 30px; height: 30px; padding: 0; }
|
|
101
140
|
|
|
102
|
-
main { display: grid; grid-template-columns: var(--chat-w) 5px 1fr;
|
|
141
|
+
main { display: grid; grid-template-columns: var(--chat-w) 5px 1fr; flex: 1; min-height: 0; }
|
|
103
142
|
body.chat-closed main { grid-template-columns: 1fr; }
|
|
104
143
|
body.chat-closed .chat, body.chat-closed #splitter { display: none; }
|
|
105
144
|
#btn-chat-toggle.active { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); font-weight: 600; }
|
package/server/api/routes.js
CHANGED
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
} from '../db.js';
|
|
18
18
|
import {
|
|
19
19
|
PLANS, effectivePlan, publicUser, createSession, deleteSession, parseAllCookieValues,
|
|
20
|
-
isLoopbackRequest, getTerminalLicenseState, activateTerminalByToken,
|
|
21
|
-
loginRemoteTerminal, setTerminalLocalPassword,
|
|
20
|
+
isLoopbackRequest, getTerminalLicenseState, activateTerminalByToken, detachTerminalLicense,
|
|
21
|
+
loginRemoteTerminal, setTerminalLocalPassword, tradingGate,
|
|
22
22
|
} from '../auth/index.js';
|
|
23
23
|
import { publicNotifyConfig, saveNotifyConfig, notifyUser } from '../notify/index.js';
|
|
24
24
|
import { PARAMS } from '../params-meta.js';
|
|
@@ -82,10 +82,16 @@ export function createRouter() {
|
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
const requireLicense = () => {
|
|
86
|
+
const gate = tradingGate();
|
|
87
|
+
if (!gate.allowNew) {
|
|
88
|
+
throw Object.assign(new Error(gate.reason), { status: 402 });
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
85
92
|
r.post('/local/activate', wrap(async (req, res) => {
|
|
86
93
|
const st = getTerminalLicenseState();
|
|
87
|
-
|
|
88
|
-
if (st.activated && !st.devBypass && !req.user && !isLoopbackRequest(req)) {
|
|
94
|
+
if (st.activated && !req.user && !isLoopbackRequest(req)) {
|
|
89
95
|
throw Object.assign(new Error('Терминал уже активирован. Войдите по своему токену личного кабинета или локальному паролю.'), { status: 409 });
|
|
90
96
|
}
|
|
91
97
|
const out = await activateTerminalByToken({
|
|
@@ -97,6 +103,8 @@ export function createRouter() {
|
|
|
97
103
|
const sid = createSession(1, 30);
|
|
98
104
|
setCookie(res, sid);
|
|
99
105
|
llmChanged();
|
|
106
|
+
hub.emit({ type: 'license:changed', license: out.state });
|
|
107
|
+
hub.emit({ type: 'brokers:update', state: brokersState() });
|
|
100
108
|
}
|
|
101
109
|
res.json(out);
|
|
102
110
|
}));
|
|
@@ -115,9 +123,21 @@ export function createRouter() {
|
|
|
115
123
|
slotId: req.body?.slotId || cur.licId || '',
|
|
116
124
|
});
|
|
117
125
|
llmChanged();
|
|
126
|
+
if (!out.chooseSlot) {
|
|
127
|
+
hub.emit({ type: 'license:changed', license: out.state });
|
|
128
|
+
hub.emit({ type: 'brokers:update', state: brokersState() });
|
|
129
|
+
}
|
|
118
130
|
res.json(out);
|
|
119
131
|
}));
|
|
120
132
|
|
|
133
|
+
r.post('/local/detach-license', wrap((req, res) => {
|
|
134
|
+
const st = detachTerminalLicense();
|
|
135
|
+
llmChanged();
|
|
136
|
+
hub.emit({ type: 'license:changed', license: st });
|
|
137
|
+
hub.emit({ type: 'brokers:update', state: brokersState() });
|
|
138
|
+
res.json({ ok: true, state: st });
|
|
139
|
+
}));
|
|
140
|
+
|
|
121
141
|
r.post('/local/password', wrap((req, res) => {
|
|
122
142
|
res.json(setTerminalLocalPassword(req.body?.password || ''));
|
|
123
143
|
}));
|
|
@@ -250,6 +270,7 @@ export function createRouter() {
|
|
|
250
270
|
}));
|
|
251
271
|
|
|
252
272
|
r.post('/model/research', wrap((req, res) => {
|
|
273
|
+
requireLicense();
|
|
253
274
|
const plan = effectivePlan();
|
|
254
275
|
if (!plan.allowResearch) {
|
|
255
276
|
throw new Error('Раздел исследований доступен только по лицензии «Хозяин».');
|
|
@@ -264,6 +285,7 @@ export function createRouter() {
|
|
|
264
285
|
}));
|
|
265
286
|
r.post('/model/:id/cancel', wrap((req, res) => { ownRunOr404(req); res.json({ ok: cancelModelResearch(Number(req.params.id)) }); }));
|
|
266
287
|
r.get('/model/forecast', wrap(async (req, res) => {
|
|
288
|
+
requireLicense();
|
|
267
289
|
const fc = await forecastNow(getSettings(), { force: req.query.force === '1' });
|
|
268
290
|
if (fc?.rows?.some((x) => x.signal || x.shortSignal)) {
|
|
269
291
|
triggerBrokerFastEntryCheck();
|
|
@@ -273,11 +295,12 @@ export function createRouter() {
|
|
|
273
295
|
|
|
274
296
|
// ---------- Описания компаний ----------
|
|
275
297
|
r.get('/profiles', wrap(async (req, res) => res.json(await profilesInfo())));
|
|
276
|
-
r.post('/profiles/refresh', wrap((req, res) => res.json(startProfilesRefresh())));
|
|
298
|
+
r.post('/profiles/refresh', wrap((req, res) => { requireLicense(); return res.json(startProfilesRefresh()); }));
|
|
277
299
|
|
|
278
300
|
// ---------- Виртуальные счета (песочница живой проверки) ----------
|
|
279
|
-
r.get('/live/status', (req, res) => res.json(live.status()));
|
|
301
|
+
r.get('/live/status', (req, res) => res.json(tradingGate().allowNew ? live.status() : { accounts: [], running: false }));
|
|
280
302
|
r.post('/live/start', wrap(async (req, res) => {
|
|
303
|
+
requireLicense();
|
|
281
304
|
const plan = effectivePlan();
|
|
282
305
|
const b = req.body || {};
|
|
283
306
|
const runId = await live.start(getSettings(), {
|
|
@@ -294,8 +317,9 @@ export function createRouter() {
|
|
|
294
317
|
|
|
295
318
|
// ---------- Брокерские счета (Т-Банк, Финам, Алор, БКС) ----------
|
|
296
319
|
r.get('/brokers', (req, res) => res.json(brokersState()));
|
|
297
|
-
r.post('/brokers/discover', wrap(async (req, res) => res.json(await discoverBrokerAccounts(req.body || {}))));
|
|
320
|
+
r.post('/brokers/discover', wrap(async (req, res) => { requireLicense(); return res.json(await discoverBrokerAccounts(req.body || {})); }));
|
|
298
321
|
r.post('/brokers', wrap((req, res) => {
|
|
322
|
+
requireLicense();
|
|
299
323
|
const plan = effectivePlan();
|
|
300
324
|
const cur = brokersState().accounts.length;
|
|
301
325
|
if (!req.body?.id && cur >= plan.maxBrokerAccounts) {
|
|
@@ -306,19 +330,19 @@ export function createRouter() {
|
|
|
306
330
|
}
|
|
307
331
|
res.json(saveBrokerAccount(req.body || {}));
|
|
308
332
|
}));
|
|
309
|
-
r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
|
|
310
|
-
r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
|
|
311
|
-
r.post('/brokers/:id/sync', wrap(async (req, res) => res.json(await syncBrokerAccount(req.params.id))));
|
|
312
|
-
r.post('/brokers/:id/history', wrap(async (req, res) => res.json(await fetchBrokerHistory(req.params.id, req.body || {}))));
|
|
313
|
-
r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => res.json(await freezeBrokerBaseline(req.params.id))));
|
|
314
|
-
r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
|
|
315
|
-
r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
|
|
316
|
-
r.post('/brokers/:id/reject', wrap((req, res) => res.json(rejectBrokerOrders(req.params.id))));
|
|
317
|
-
r.post('/brokers/:id/cancel-order', wrap(async (req, res) => res.json(await cancelBrokerOrder(req.params.id, req.body?.orderId || null))));
|
|
318
|
-
r.post('/brokers/:id/reset-daily-stop', wrap((req, res) => res.json(resetBrokerDailyStop(req.params.id))));
|
|
319
|
-
r.get('/brokers/:id/events', wrap((req, res) => res.json({ events: listBrokerEvents(req.params.id, req.query.limit) })));
|
|
320
|
-
r.get('/brokers/:id/decisions', wrap((req, res) => res.json({ decisions: listTradeDecisions(req.params.id, { fromTs: req.query.from, limit: req.query.limit }) })));
|
|
321
|
-
r.post('/brokers-stop-all', wrap(async (req, res) => res.json(await stopAllBrokerTrading())));
|
|
333
|
+
r.put('/brokers/:id', wrap((req, res) => { requireLicense(); return res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id })); }));
|
|
334
|
+
r.delete('/brokers/:id', wrap((req, res) => { requireLicense(); return res.json(deleteBrokerAccount(req.params.id)); }));
|
|
335
|
+
r.post('/brokers/:id/sync', wrap(async (req, res) => { requireLicense(); return res.json(await syncBrokerAccount(req.params.id)); }));
|
|
336
|
+
r.post('/brokers/:id/history', wrap(async (req, res) => { requireLicense(); return res.json(await fetchBrokerHistory(req.params.id, req.body || {})); }));
|
|
337
|
+
r.post('/brokers/:id/freeze-baseline', wrap(async (req, res) => { requireLicense(); return res.json(await freezeBrokerBaseline(req.params.id)); }));
|
|
338
|
+
r.post('/brokers/:id/prepare', wrap(async (req, res) => { requireLicense(); return res.json(await prepareBrokerOrders(req.params.id)); }));
|
|
339
|
+
r.post('/brokers/:id/execute', wrap(async (req, res) => { requireLicense(); return res.json(await executeBrokerOrders(req.params.id)); }));
|
|
340
|
+
r.post('/brokers/:id/reject', wrap((req, res) => { requireLicense(); return res.json(rejectBrokerOrders(req.params.id)); }));
|
|
341
|
+
r.post('/brokers/:id/cancel-order', wrap(async (req, res) => { requireLicense(); return res.json(await cancelBrokerOrder(req.params.id, req.body?.orderId || null)); }));
|
|
342
|
+
r.post('/brokers/:id/reset-daily-stop', wrap((req, res) => { requireLicense(); return res.json(resetBrokerDailyStop(req.params.id)); }));
|
|
343
|
+
r.get('/brokers/:id/events', wrap((req, res) => { requireLicense(); return res.json({ events: listBrokerEvents(req.params.id, req.query.limit) }); }));
|
|
344
|
+
r.get('/brokers/:id/decisions', wrap((req, res) => { requireLicense(); return res.json({ decisions: listTradeDecisions(req.params.id, { fromTs: req.query.from, limit: req.query.limit }) }); }));
|
|
345
|
+
r.post('/brokers-stop-all', wrap(async (req, res) => { requireLicense(); return res.json(await stopAllBrokerTrading()); }));
|
|
322
346
|
|
|
323
347
|
// ---------- Новости ----------
|
|
324
348
|
r.get('/news', (req, res) => res.json({
|
|
@@ -327,6 +351,7 @@ export function createRouter() {
|
|
|
327
351
|
|
|
328
352
|
// ---------- Досье акции ----------
|
|
329
353
|
r.get('/stock/:symbol', wrap(async (req, res) => {
|
|
354
|
+
requireLicense();
|
|
330
355
|
const sym = String(req.params.symbol || '').toUpperCase().trim();
|
|
331
356
|
if (!sym || sym.length > 20) throw new Error('Некорректный тикер');
|
|
332
357
|
|
package/server/auth/index.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
// Однопользовательский контур безопасности и автономного лицензирования торгового терминала НейроАльфа:
|
|
3
|
-
// • шифрование локальных брокерских ключей (AES-256-GCM в
|
|
4
|
-
// •
|
|
5
|
-
// •
|
|
6
|
-
// •
|
|
2
|
+
// Однопользовательский контур безопасности и строгого автономного лицензирования торгового терминала НейроАльфа:
|
|
3
|
+
// • шифрование локальных брокерских ключей (AES-256-GCM в DATA_DIR/.master.key);
|
|
4
|
+
// • НИКАКИХ dev-обходов: без привязанного токена и действующей подписанной лицензии (Ed25519) торговля и сигналы отключены;
|
|
5
|
+
// • на локальном ПК (127.0.0.1) открывается интерфейс терминала с верхней красной панелью error и переходом в раздел «Лицензия»;
|
|
6
|
+
// • по внешней сети / на VPS вход в терминал закрыт токеном ЛК или локальным паролем.
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
|
-
import { ROOT, DATA_DIR,
|
|
12
|
+
import { ROOT, DATA_DIR, DEFAULT_CLOUD_URL } from '../config.js';
|
|
13
13
|
import { IS_PACKAGED, BUILD } from '../build-info.js';
|
|
14
14
|
import { db, kvGet, kvSet, registerUidResolver } from '../db.js';
|
|
15
15
|
|
|
@@ -212,30 +212,27 @@ export function verifyLicenseCertificate(licenseToken, publicKeyPem, expectedIns
|
|
|
212
212
|
return p;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
const isDevUnactivatedBypass = () => IS_DEV_REPO && process.env.NEUROALPHA_STRICT_LICENSE !== '1';
|
|
216
|
-
|
|
217
215
|
export function getTerminalLicenseState() {
|
|
218
216
|
const stored = kvGet('terminalLicense') || null;
|
|
219
217
|
const localPassHash = kvGet('terminalLocalPassHash') || '';
|
|
220
218
|
if (!stored?.licenseToken || !stored?.publicKeyPem) {
|
|
221
|
-
const devBypass = isDevUnactivatedBypass();
|
|
222
219
|
return {
|
|
223
|
-
activated:
|
|
224
|
-
|
|
225
|
-
valid: devBypass,
|
|
220
|
+
activated: false,
|
|
221
|
+
valid: false,
|
|
226
222
|
expired: false,
|
|
227
|
-
plan:
|
|
228
|
-
planName:
|
|
223
|
+
plan: 'none',
|
|
224
|
+
planName: PLANS.none.name,
|
|
229
225
|
validUntil: 0,
|
|
230
|
-
perpetual:
|
|
231
|
-
email:
|
|
232
|
-
name:
|
|
226
|
+
perpetual: false,
|
|
227
|
+
email: '',
|
|
228
|
+
name: '',
|
|
233
229
|
licId: '',
|
|
234
230
|
label: '',
|
|
235
231
|
portalUrl: stored?.portalUrl || DEFAULT_CLOUD_URL,
|
|
236
232
|
hasCabinetToken: Boolean(stored?.cabinetTokenEnc),
|
|
237
233
|
hasLocalPassword: Boolean(localPassHash),
|
|
238
234
|
installId: getInstallId(),
|
|
235
|
+
slots: Array.isArray(stored?.allSlots) ? stored.allSlots : [],
|
|
239
236
|
};
|
|
240
237
|
}
|
|
241
238
|
|
|
@@ -247,7 +244,6 @@ export function getTerminalLicenseState() {
|
|
|
247
244
|
const planId = !expired && PLANS[p.plan] ? p.plan : 'none';
|
|
248
245
|
return {
|
|
249
246
|
activated: true,
|
|
250
|
-
devBypass: false,
|
|
251
247
|
valid: !expired && planId !== 'none',
|
|
252
248
|
expired,
|
|
253
249
|
plan: planId,
|
|
@@ -264,11 +260,11 @@ export function getTerminalLicenseState() {
|
|
|
264
260
|
hasLocalPassword: Boolean(localPassHash),
|
|
265
261
|
installId: getInstallId(),
|
|
266
262
|
lastFetchedAt: stored.fetchedAt || 0,
|
|
263
|
+
slots: Array.isArray(stored.allSlots) ? stored.allSlots : [],
|
|
267
264
|
};
|
|
268
265
|
} catch (err) {
|
|
269
266
|
return {
|
|
270
267
|
activated: false,
|
|
271
|
-
devBypass: false,
|
|
272
268
|
valid: false,
|
|
273
269
|
expired: false,
|
|
274
270
|
error: err.message,
|
|
@@ -284,6 +280,7 @@ export function getTerminalLicenseState() {
|
|
|
284
280
|
hasCabinetToken: Boolean(stored.cabinetTokenEnc),
|
|
285
281
|
hasLocalPassword: Boolean(localPassHash),
|
|
286
282
|
installId: getInstallId(),
|
|
283
|
+
slots: Array.isArray(stored.allSlots) ? stored.allSlots : [],
|
|
287
284
|
};
|
|
288
285
|
}
|
|
289
286
|
}
|
|
@@ -295,7 +292,15 @@ export function getStoredCabinetCredentials() {
|
|
|
295
292
|
return { token, portalUrl };
|
|
296
293
|
}
|
|
297
294
|
|
|
298
|
-
|
|
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_...)
|
|
299
304
|
export async function activateTerminalByToken({ token = '', portalUrl = '', slotId = '' } = {}) {
|
|
300
305
|
const stored = kvGet('terminalLicense') || {};
|
|
301
306
|
const cleanToken = String(token || decryptSecret(stored.cabinetTokenEnc || '')).trim();
|
|
@@ -328,7 +333,6 @@ export async function activateTerminalByToken({ token = '', portalUrl = '', slot
|
|
|
328
333
|
throw new Error(j.error || `Портал вернул ошибку HTTP ${r.status}`);
|
|
329
334
|
}
|
|
330
335
|
|
|
331
|
-
// Если в ЛК несколько свободных слотов лицензий — возвращаем список для выбора пользователем
|
|
332
336
|
if (j.chooseSlot) {
|
|
333
337
|
return {
|
|
334
338
|
chooseSlot: true,
|
|
@@ -336,6 +340,7 @@ export async function activateTerminalByToken({ token = '', portalUrl = '', slot
|
|
|
336
340
|
email: j.email || '',
|
|
337
341
|
name: j.name || '',
|
|
338
342
|
slots: j.slots || [],
|
|
343
|
+
allSlots: j.allSlots || j.slots || [],
|
|
339
344
|
};
|
|
340
345
|
}
|
|
341
346
|
|
|
@@ -343,6 +348,7 @@ export async function activateTerminalByToken({ token = '', portalUrl = '', slot
|
|
|
343
348
|
throw new Error('Портал не вернул подписанный сертификат лицензии');
|
|
344
349
|
}
|
|
345
350
|
const verified = verifyLicenseCertificate(j.licenseToken, j.publicKeyPem, getInstallId());
|
|
351
|
+
const allSlots = Array.isArray(j.allSlots) ? j.allSlots : (j.slot ? [j.slot] : []);
|
|
346
352
|
|
|
347
353
|
kvSet('terminalLicense', {
|
|
348
354
|
portalUrl: basePortal,
|
|
@@ -351,10 +357,10 @@ export async function activateTerminalByToken({ token = '', portalUrl = '', slot
|
|
|
351
357
|
cabinetTokenHash: sha256Hex(cleanToken),
|
|
352
358
|
slotId: verified.licId || j.slot?.id || '',
|
|
353
359
|
licenseToken: j.licenseToken,
|
|
360
|
+
allSlots,
|
|
354
361
|
fetchedAt: Date.now(),
|
|
355
362
|
});
|
|
356
363
|
|
|
357
|
-
// Синхронизируем облачный ключ для ИИ-прокси портала и шлюза Telegram/MAX
|
|
358
364
|
kvSet('cloudLicense', {
|
|
359
365
|
apiKey: cleanToken,
|
|
360
366
|
cloudUrl: basePortal,
|
|
@@ -380,7 +386,6 @@ export async function activateTerminalByToken({ token = '', portalUrl = '', slot
|
|
|
380
386
|
};
|
|
381
387
|
}
|
|
382
388
|
|
|
383
|
-
// Обратная совместимость с CLI-флагом при запуске
|
|
384
389
|
export async function activateLocalLicense(token, portalUrl = '') {
|
|
385
390
|
const out = await activateTerminalByToken({ token, portalUrl });
|
|
386
391
|
return out.state || out;
|
|
@@ -392,9 +397,9 @@ export function startAutonomousLicenseWatcher() {
|
|
|
392
397
|
const checkIfExpiringSoon = async () => {
|
|
393
398
|
try {
|
|
394
399
|
const st = getTerminalLicenseState();
|
|
395
|
-
if (!st.activated || st.
|
|
400
|
+
if (!st.activated || st.perpetual || !st.hasCabinetToken) return;
|
|
396
401
|
const leftMs = (Number(st.validUntil) || 0) - Date.now();
|
|
397
|
-
if (leftMs > RENEW_WINDOW_MS) return;
|
|
402
|
+
if (leftMs > RENEW_WINDOW_MS) return;
|
|
398
403
|
if (Date.now() - lastAutoRefreshAttemptTs < 12 * 3600000) return;
|
|
399
404
|
lastAutoRefreshAttemptTs = Date.now();
|
|
400
405
|
const stored = kvGet('terminalLicense') || {};
|
|
@@ -405,15 +410,17 @@ export function startAutonomousLicenseWatcher() {
|
|
|
405
410
|
timer.unref?.();
|
|
406
411
|
}
|
|
407
412
|
|
|
408
|
-
//
|
|
409
|
-
// без активной лицензии новые входы блокируются, но защитные стопы продолжают работать!
|
|
413
|
+
// Строгое правило: БЕЗ ДЕЙСТВУЮЩЕЙ ЛИЦЕНЗИИ — НИКАКОЙ ТОРГОВЛИ (allowNew = false, allowProtective = false)!
|
|
410
414
|
export function tradingGate() {
|
|
411
415
|
const st = getTerminalLicenseState();
|
|
412
|
-
if (!st.activated) {
|
|
413
|
-
return {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
if (!st.activated || !st.valid) {
|
|
417
|
+
return {
|
|
418
|
+
allowNew: false,
|
|
419
|
+
allowProtective: false,
|
|
420
|
+
reason: st.expired
|
|
421
|
+
? 'Срок действия лицензии истёк — торговля и сигналы полностью остановлены. Загрузите продлённую лицензию в разделе «Лицензия».'
|
|
422
|
+
: 'Лицензия не активна — торговля и сигналы отключены. Откройте раздел «Лицензия» и загрузите лицензию по токену личного кабинета.',
|
|
423
|
+
};
|
|
417
424
|
}
|
|
418
425
|
return { allowNew: true, allowProtective: true, reason: '' };
|
|
419
426
|
}
|
|
@@ -502,13 +509,14 @@ export function publicUser(u) {
|
|
|
502
509
|
if (!u) return null;
|
|
503
510
|
const st = getTerminalLicenseState();
|
|
504
511
|
const plan = effectivePlan();
|
|
505
|
-
const isOwner = Boolean(plan.id === 'owner' || plan.allowResearch);
|
|
512
|
+
const isOwner = Boolean(st.valid && (plan.id === 'owner' || plan.allowResearch));
|
|
506
513
|
return {
|
|
507
514
|
id: 1,
|
|
508
|
-
email: st.
|
|
509
|
-
name: st.
|
|
515
|
+
email: st.valid ? (st.email || '') : '',
|
|
516
|
+
name: st.valid ? (st.name || 'Пользователь') : 'Не активирован',
|
|
510
517
|
role: isOwner ? 'admin' : 'user',
|
|
511
518
|
isOwner,
|
|
519
|
+
licensed: Boolean(st.valid),
|
|
512
520
|
plan: plan.id,
|
|
513
521
|
planName: plan.name,
|
|
514
522
|
planUntil: st.perpetual ? 0 : (st.validUntil || 0),
|
|
@@ -552,16 +560,15 @@ export function deleteSession(token) {
|
|
|
552
560
|
}
|
|
553
561
|
|
|
554
562
|
export function userFromRequest(req) {
|
|
555
|
-
|
|
556
|
-
if (!st.activated) return null;
|
|
557
|
-
|
|
563
|
+
// 1) По активной cookie-сессии (для VPS и удалённого доступа)
|
|
558
564
|
const sids = parseAllCookieValues(req.headers?.cookie, 'sid').filter((s) => s && s !== 'logged_out');
|
|
559
565
|
for (const token of sids) {
|
|
560
566
|
const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
|
|
561
567
|
if (sess) return getUserById(1);
|
|
562
568
|
}
|
|
563
569
|
|
|
564
|
-
// На локальном компьютере (127.0.0.1)
|
|
570
|
+
// 2) На локальном компьютере (localhost / 127.0.0.1) открываем сам интерфейс терминала сразу
|
|
571
|
+
// (при отсутствии лицензии вверху горит красная плашка error, а все торговые контуры закрыты)
|
|
565
572
|
const explicitlyLoggedOut = parseAllCookieValues(req.headers?.cookie, 'sid').includes('logged_out');
|
|
566
573
|
if (!explicitlyLoggedOut && isLoopbackRequest(req)) {
|
|
567
574
|
return getUserById(1);
|
package/server/brokers/index.js
CHANGED
|
@@ -528,14 +528,26 @@ export function getActiveAccountSignalFilters(nowTs = Date.now()) {
|
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
export function brokersState() {
|
|
531
|
+
const gate = tradingGate();
|
|
532
|
+
if (!gate.allowNew) {
|
|
533
|
+
return {
|
|
534
|
+
brokers: BROKER_META,
|
|
535
|
+
accounts: [],
|
|
536
|
+
trading: gate,
|
|
537
|
+
unlicensed: true,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
531
540
|
return {
|
|
532
541
|
brokers: BROKER_META,
|
|
533
542
|
accounts: loadRawAccounts().map(publicAccount),
|
|
534
|
-
trading:
|
|
543
|
+
trading: gate,
|
|
544
|
+
unlicensed: false,
|
|
535
545
|
};
|
|
536
546
|
}
|
|
537
547
|
|
|
538
548
|
export function saveBrokerAccount(input = {}) {
|
|
549
|
+
const gate = tradingGate();
|
|
550
|
+
if (!gate.allowNew) throw new Error(gate.reason);
|
|
539
551
|
const list = loadRawAccounts();
|
|
540
552
|
const plan = effectivePlan(currentUser());
|
|
541
553
|
const id = input.id && String(input.id).trim() ? String(input.id).trim() : `brk_${crypto.randomBytes(4).toString('hex')}`;
|
package/server/index.js
CHANGED
|
@@ -167,9 +167,9 @@ server.listen(PORT, LISTEN_HOST, () => {
|
|
|
167
167
|
let ver = '';
|
|
168
168
|
try { ver = ` v${JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version}`; } catch { /* ignore */ }
|
|
169
169
|
const lic = getTerminalLicenseState();
|
|
170
|
-
const licInfo = lic.
|
|
171
|
-
?
|
|
172
|
-
: '
|
|
170
|
+
const licInfo = lic.valid
|
|
171
|
+
? `${lic.planName}${lic.perpetual ? ' (бессрочно)' : ''}`
|
|
172
|
+
: 'лицензия не активна (торговля отключена)';
|
|
173
173
|
console.log(`◈ НейроАльфа Терминал${ver} запущен · ${licInfo}`);
|
|
174
174
|
console.log(` Данные: ${DATA_DIR}`);
|
|
175
175
|
const llm = activeModelInfo();
|
package/server/model/live.js
CHANGED
|
@@ -11,7 +11,7 @@ import { STRATEGY_POLICY } from './policy.js';
|
|
|
11
11
|
import { computeReport, downsample } from './metrics.js';
|
|
12
12
|
import { MODELS } from './models.js';
|
|
13
13
|
import { blockingNews } from '../news/index.js';
|
|
14
|
-
import { currentUserId } from '../auth/index.js';
|
|
14
|
+
import { currentUserId, tradingGate } from '../auth/index.js';
|
|
15
15
|
import { notifyUser } from '../notify/index.js';
|
|
16
16
|
|
|
17
17
|
const dayKey = (ts) => new Date(ts + MSK).toISOString().slice(0, 10);
|
|
@@ -256,6 +256,7 @@ class LiveEngine {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
async #tickAccount(s, latest) {
|
|
259
|
+
if (!tradingGate().allowNew) return;
|
|
259
260
|
if (!s.running || s.busy || s.suspended) return;
|
|
260
261
|
if (latest !== null && latest === s.lastSeenTs) return;
|
|
261
262
|
s.busy = true;
|
|
@@ -9,6 +9,7 @@ import { getSettings } from '../settings.js';
|
|
|
9
9
|
import { brokersState } from '../brokers/index.js';
|
|
10
10
|
import { tradingSessionStatus } from '../session.js';
|
|
11
11
|
import { registerChatHandlers } from '../notify/index.js';
|
|
12
|
+
import { tradingGate } from '../auth/index.js';
|
|
12
13
|
import {
|
|
13
14
|
currentConversation, newConversation, getConversation, setConversationTitle, listConversations,
|
|
14
15
|
addMessage, updateMessage, getMessage, getMessages,
|
|
@@ -430,8 +431,10 @@ async function execTool(msg, call, mode) {
|
|
|
430
431
|
const tool = TOOLS[name];
|
|
431
432
|
const args = parseArgs(call);
|
|
432
433
|
const mObj = MODES[mode] || MODES.advisor;
|
|
434
|
+
const gate = tradingGate();
|
|
433
435
|
let res;
|
|
434
|
-
if (!
|
|
436
|
+
if (!gate.allowNew) res = { result: { ошибка: gate.reason }, summary: 'Лицензия не активна', ok: false };
|
|
437
|
+
else if (!tool) res = { result: { ошибка: `нет инструмента ${name}` }, summary: `Неизвестный инструмент ${name}`, ok: false };
|
|
435
438
|
else if (!mObj.allowed(tool)) res = { result: { ошибка: 'это действие сейчас недоступно' }, summary: 'Недоступно', ok: false };
|
|
436
439
|
else if (args === null) res = { result: { ошибка: 'не удалось разобрать аргументы (нужен JSON)' }, summary: 'Ошибка в аргументах', ok: false };
|
|
437
440
|
else {
|