neuroalpha 0.7.0 → 0.7.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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
- $('#btn-user')?.addEventListener('click', () => {
3957
- switchTab('settings');
3958
- showSettingsSec('cloud');
3959
- renderCloudPane();
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
- if (!u) {
3977
- box.innerHTML = `<div class="srow sblock"><div class="stxt"><div class="slbl">Терминал не активирован</div><div class="hint">Перезагрузите страницу и введите токен из личного кабинета портала.</div></div><div class="row-gap"><a class="btn primary" href="/">Открыть экран активации</a></div></div>`;
3978
- return;
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.devBypass
3982
- ? 'режим разработчика (локальный git-репозиторий)'
3983
- : lic.perpetual
3984
- ? 'действует бессрочно (100% автономно)'
3985
- : lic.validUntil
3986
- ? `автономно оплачена до <b>${fmtDate(lic.validUntil)}</b>`
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.limits?.maxCapitalPerAccountRub > 0
4068
+ : u?.limits?.maxCapitalPerAccountRub > 0
3991
4069
  ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ вся прибыль)`
3992
- : 'нет активной лицензии';
3993
- const dirLimStr = u.limits?.allowShorts ? 'покупки и шорты (Long / Short)' : 'только покупки (Long)';
3994
- const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
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
- Статус: ${validStr}${lic.email ? ` · Аккаунт: <b>${esc(lic.email)}</b>` : ''}<br>
4005
- Квота счетов: до <b>${u.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Направление: <b>${dirLimStr}</b>.<br>
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">↻ Обновить лицензию с портала</button>' : ''}
4011
- <a class="btn" href="${esc(portalUrl)}" target="_blank" rel="noopener">Личный кабинет на портале →</a>
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">Активировать или сменить токен личного кабинета (<code>na_tok_…</code>)</div>
4019
- <div class="hint">После продления тарифа или покупки новой лицензии на портале вставьте ваш токен ниже (или нажмите «Обновить лицензию», если токен уже сохранён).</div>
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:280px;font-family:monospace" required>
4023
- <input name="portalUrl" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" style="width:230px">
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
- С этого же компьютера (<code>localhost</code>) терминал открывается сразу. При обращении по сети / IP сервер требует ввести ваш токен <code>na_tok_…</code> либо дополнительный локальный пароль (${lic.hasLocalPassword ? '<b class="pos">установлен ✓</b>' : 'не задан'}).
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
- По умолчанию уведомления и кнопки подтверждения заявок работают «из коробки» через официальных ботов портала (привяжите Telegram или MAX в личном кабинете на <a href="${esc(portalUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">${esc(portalUrl.replace(/^https?:\/\//, ''))}</a>). При желании ниже можно подключить собственного бота напрямую без участия портала.
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 r = await post('/api/local/refresh-license', {});
4073
- toast(`✓ Лицензия обновлена: ${r.state?.planName || 'активна'}`);
4074
- await renderCloudPane();
4075
- await loadLlm();
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', { token: f.token.value.trim(), portalUrl: f.portalUrl.value.trim() });
4084
- if (r.chooseSlot && r.slots?.length) {
4085
- const firstId = r.slots[0].id;
4086
- await post('/api/local/activate', { token: f.token.value.trim(), portalUrl: f.portalUrl.value.trim(), slotId: firstId });
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 renderCloudPane();
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 (urlParams.get('run') && isOwnerUser()) {
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
- <a href="/landing" class="hdr-btn" title="Публичная витрина и тарифы">Витрина</a>
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">Лицензия и уведомления</button>
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">Автономная лицензия, доступ и уведомления (Telegram / MAX)</div>
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; height: calc(100vh - 48px); }
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; }
@@ -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
 
@@ -1,15 +1,15 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Однопользовательский контур безопасности и автономного лицензирования торгового терминала НейроАльфа:
3
- // • шифрование локальных брокерских ключей (AES-256-GCM в ~/.neuroalpha/.master.key);
4
- // • активация терминала по токену личного кабинета (na_tok_...) и офлайн-проверка подписи лицензии (Ed25519);
5
- // • 100% автономная работа без обращений к порталу до последних 3 суток перед истечением оплаченного срока;
6
- // • защита удалённого доступа (на VPS): с localhost (127.0.0.1) вход открыт после активации, по сети требуется токен ЛК или локальный пароль.
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, IS_DEV_REPO, DEFAULT_CLOUD_URL } from '../config.js';
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: devBypass,
224
- devBypass,
225
- valid: devBypass,
220
+ activated: false,
221
+ valid: false,
226
222
  expired: false,
227
- plan: devBypass ? 'owner' : 'none',
228
- planName: devBypass ? PLANS.owner.name : PLANS.none.name,
223
+ plan: 'none',
224
+ planName: PLANS.none.name,
229
225
  validUntil: 0,
230
- perpetual: devBypass,
231
- email: devBypass ? 'dev@local' : '',
232
- name: devBypass ? 'Разработчик (локальный репозиторий)' : '',
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
- // Скачивание подписанной автономной лицензии с портала по токену ЛК (na_tok_...)
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.devBypass || st.perpetual || !st.hasCabinetToken) return;
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; // больше 3 суток впереди — полная тишина и автономность!
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
- // Предохранитель торгового движка (server/brokers/index.js):
409
- // без активной лицензии новые входы блокируются, но защитные стопы продолжают работать!
413
+ // Строгое правило: БЕЗ ДЕЙСТВУЮЩЕЙ ЛИЦЕНЗИИ — НИКАКОЙ ТОРГОВЛИ (allowNew = false, allowProtective = false)!
410
414
  export function tradingGate() {
411
415
  const st = getTerminalLicenseState();
412
- if (!st.activated) {
413
- return { allowNew: false, allowProtective: true, reason: 'Терминал не активирован — введите токен из личного кабинета' };
414
- }
415
- if (st.expired || !st.valid) {
416
- return { allowNew: false, allowProtective: true, reason: 'Срок действия лицензии истёк — продлите подписку в личном кабинете и обновите лицензию (защитные стопы работают)' };
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.email || u.email || '',
509
- name: st.name || u.name || 'Пользователь',
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
- const st = getTerminalLicenseState();
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);
@@ -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: tradingGate(),
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.activated
171
- ? (lic.devBypass ? 'режим разработчика' : `${lic.planName}${lic.perpetual ? ' (бессрочно)' : ''}`)
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();
@@ -177,15 +177,6 @@ function init() {
177
177
  ready = true;
178
178
  if (kvGet('llmProviders') === null) kvSet('llmProviders', []);
179
179
  ensureEnv();
180
- // разовая миграция: подключения, созданные до настройки сертификата в интерфейсе, получают прежний путь к сертификату НУЦ (только как данные — можно изменить или удалить)
181
- if (!kvGet('gigaCaMigrated')) {
182
- const list = kvGet('llmProviders', []);
183
- const cert = path.resolve(ROOT, '../work/#system/certs/russian_trusted_root_ca.pem');
184
- if (fs.existsSync(cert)) for (const p of list) if (p.protocol === 'gigachat' && !p.caFile) p.caFile = cert;
185
- kvSet('llmProviders', list);
186
- kvSet('gigaCaMigrated', true);
187
- }
188
- ensureEnv();
189
180
  ensureCloudDefault();
190
181
  const list = kvGet('llmProviders', []);
191
182
  if (!kvGet('llmSelected') && list.length) {
@@ -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 (!tool) res = { result: { ошибка: `нет инструмента ${name}` }, summary: `Неизвестный инструмент ${name}`, ok: false };
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 {
package/server/tls-ca.js CHANGED
@@ -10,7 +10,6 @@ const CANDIDATES = [
10
10
  process.env.BROKER_CA_FILE,
11
11
  process.env.GIGACHAT_CA_FILE,
12
12
  path.join(ROOT, 'config', 'certs', 'russian_trusted_root_ca.pem'),
13
- path.resolve(ROOT, '../work/#system/certs/russian_trusted_root_ca.pem'),
14
13
  ].filter(Boolean);
15
14
 
16
15
  let russianRootPem = '';