neuroalpha 0.6.13 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/bin/cli.js +79 -69
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
- package/package.json +4 -5
- package/public/activate.html +41 -19
- package/public/activate.js +81 -10
- package/public/app.js +300 -152
- package/public/index.html +4 -37
- package/server/api/routes.js +570 -906
- package/server/auth/index.js +576 -761
- package/server/brokers/index.js +1 -2
- package/server/config.js +73 -53
- package/server/index.js +186 -212
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/notify/index.js +304 -466
- package/deploy/docs/01-central.md +0 -96
- package/deploy/docs/02-cloud-instances.md +0 -130
- package/deploy/docs/03-desktop-build.md +0 -92
- package/deploy/docs/04-client-guide.md +0 -101
- package/deploy/docs/05-operations.md +0 -109
- package/deploy/docs/06-env-reference.md +0 -91
- package/deploy/docs/07-stress-test.md +0 -253
- package/deploy/docs/pdf/01-central.pdf +0 -0
- package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
- package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
- package/deploy/docs/pdf/04-client-guide.pdf +0 -0
- package/deploy/docs/pdf/05-operations.pdf +0 -0
- package/deploy/docs/pdf/06-env-reference.pdf +0 -0
- package/deploy/docs/pdf/07-stress-test.pdf +0 -0
- package/deploy/install.sh +0 -128
- package/deploy/k8s/instance.yaml +0 -147
- package/public/admin.html +0 -604
- package/public/landing.html +0 -1207
- package/public/legal.html +0 -134
- package/scripts/set-admin-password.mjs +0 -30
- package/server/billing/yookassa.js +0 -142
- package/server/instance/agent.js +0 -259
- package/server/instance/central.js +0 -514
- package/server/instance/identity.js +0 -79
- package/server/instance/provision.js +0 -166
- package/server/instance/signing.js +0 -71
- package/server/role.js +0 -44
package/public/legal.html
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
<!-- Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software. -->
|
|
2
|
-
<!doctype html>
|
|
3
|
-
<html lang="ru">
|
|
4
|
-
<head>
|
|
5
|
-
<meta charset="utf-8">
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
-
<title>Правовые документы · НейроАльфа</title>
|
|
8
|
-
<style>
|
|
9
|
-
:root { color-scheme: dark; --bg: #0b0e14; --panel: #131822; --line: #242c3d; --text: #eef2f6; --muted: #94a3b8; --accent: #3b82f6; }
|
|
10
|
-
* { box-sizing: border-box; }
|
|
11
|
-
body { margin: 0; background: var(--bg); color: var(--text); font: 15.5px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
12
|
-
a, a:visited { color: var(--accent); text-decoration: none; }
|
|
13
|
-
a.brand, a.brand:visited { color: var(--text); }
|
|
14
|
-
.wrap { max-width: 900px; margin: 0 auto; padding: 36px 24px 70px; }
|
|
15
|
-
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
|
16
|
-
.brand { font-weight: 700; font-size: 19px; color: var(--text); }
|
|
17
|
-
.doc { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 28px 32px; margin-bottom: 24px; }
|
|
18
|
-
h1 { font-size: 28px; margin: 0 0 18px; }
|
|
19
|
-
h2 { font-size: 21px; margin: 0 0 12px; color: #fff; }
|
|
20
|
-
h3 { font-size: 16px; margin: 18px 0 6px; color: #cbd5e1; }
|
|
21
|
-
p, li { color: var(--muted); margin: 8px 0; }
|
|
22
|
-
ul { padding-left: 20px; }
|
|
23
|
-
</style>
|
|
24
|
-
</head>
|
|
25
|
-
<body>
|
|
26
|
-
<div class="wrap">
|
|
27
|
-
<div class="top">
|
|
28
|
-
<a class="brand" href="/landing">◈ НейроАльфа</a>
|
|
29
|
-
<div style="display:flex;gap:16px;font-size:14px">
|
|
30
|
-
<a href="#offer">Публичная оферта</a>
|
|
31
|
-
<a href="#privacy">Политика 152-ФЗ</a>
|
|
32
|
-
<a href="#risks">Декларация о рисках</a>
|
|
33
|
-
<a href="/landing">← Назад на витрину</a>
|
|
34
|
-
</div>
|
|
35
|
-
</div>
|
|
36
|
-
|
|
37
|
-
<h1>Правовые документы сервиса «НейроАльфа»</h1>
|
|
38
|
-
|
|
39
|
-
<section class="doc" id="offer">
|
|
40
|
-
<h2>1. Лицензионная публичная оферта (Договор предоставления права использования ПО и аренды инфраструктуры PaaS)</h2>
|
|
41
|
-
<p>Настоящий документ является официальным предложением (публичной офертой в соответствии со ст. 435, ч. 2 ст. 437 и ст. 1286 Гражданского кодекса РФ) Правообладателя и Оператора платформы «НейроАльфа» (далее — Лицензиар / Исполнитель) любому дееспособному физическому или юридическому лицу (далее — Лицензиат / Пользователь) заключить договор на предоставление простой (неисключительной) лицензии на право использования программного обеспечения для ЭВМ «НейроАльфа» и аренду выделенной виртуальной машины PaaS.</p>
|
|
42
|
-
|
|
43
|
-
<h3>1.1. Предмет договора и правовой статус программного обеспечения</h3>
|
|
44
|
-
<ul>
|
|
45
|
-
<li>Лицензиар предоставляет Пользователю за вознаграждение простую (неисключительную) лицензию на право дистанционного использования программно-вычислительного комплекса «НейроАльфа» (модель PaaS или локальный дистрибутив на тарифе VIP) в соответствии с выбранным тарифным планом («Инвестор», «Профи», «VIP»).</li>
|
|
46
|
-
<li>Оплата по настоящему Договору взимается <b>исключительно за предоставление права использования программного обеспечения (лицензии)</b> и выделенные вычислительные ресурсы PaaS.</li>
|
|
47
|
-
<li>Программный комплекс «НейроАльфа» <b>не является сервисом по зарабатыванию денег</b>, а представляет собой исключительно техническое программное обеспечение для ЭВМ (статистический калькулятор котировок и технический шлюз автоматизации обмена командами с API брокеров по параметрам и предохранителям, заданным самим Пользователем).</li>
|
|
48
|
-
<li><b>Исполнитель не осуществляет деятельность по инвестиционному консультированию</b> (в смысле ст. 6.1 Федерального закона № 39-ФЗ «О рынке ценных бумаг»), не составляет индивидуальных инвестиционных профилей и не даёт индивидуальных инвестиционных рекомендаций (ИИР).</li>
|
|
49
|
-
<li><b>Исполнитель не осуществляет доверительное управление</b> или брокерскую деятельность, не принимает денежные средства или ценные бумаги Пользователей на свои счета. Все торговые операции совершаются Пользователем самостоятельно на его личном счёте у лицензированного брокера.</li>
|
|
50
|
-
</ul>
|
|
51
|
-
|
|
52
|
-
<h3>1.2. Тарифные планы и технические параметры мощности</h3>
|
|
53
|
-
<ul>
|
|
54
|
-
<li><b>Тариф «Инвестор»:</b> позволяет подключить 1 брокерский счёт со стартовым лимитом капитала до 1 000 000 ₽ (+ реинвестирование всей заработанной прибыли), работающий исключительно в режиме покупок («Только в рост (без шорта)»).</li>
|
|
55
|
-
<li><b>Тариф «Профи»:</b> позволяет подключить до 5 брокерских счетов со стартовым лимитом капитала до 1 000 000 ₽ на каждом счёте (+ реинвестирование всей заработанной прибыли), включает режим работы на росте и падении акций («В рост и в падение (с шортом)»).</li>
|
|
56
|
-
<li><b>Тариф «VIP»:</b> предоставляется по индивидуальному согласованию с Администрацией платформы (через персональный VIP-токен), количество счетов и начальная сумма торговли определяются по согласованию, включает работу с шортами.</li>
|
|
57
|
-
</ul>
|
|
58
|
-
|
|
59
|
-
<h3>1.3. Запрет использования автоследования и ретрансляции сигналов</h3>
|
|
60
|
-
<ul>
|
|
61
|
-
<li>Пользователю <b>строго запрещается</b> подключать к платформе «НейроАльфа» брокерские счета (субсчета), участвующие в любых сервисах автоследования или копирования сделок (как в роли автора/ведущего стратегии, так и в роли подписчика), включая, но не ограничиваясь: «Сигналы» Т-Инвестиций, Finam Comon, Автоследование БКС и сторонние копи-трейдинг сервисы.</li>
|
|
62
|
-
<li>Пользователю запрещается передавать, публиковать, ретранслировать или перепродавать третьим лицам формируемые платформой расчёты, прогнозы и пакеты торговых поручений.</li>
|
|
63
|
-
<li>Платформа оснащена автоматическим техническим контролем признаков автоследования. При обнаружении на подключённом субсчёте операций автоследования или ретрансляции сделок подача поручений по счёту автоматически блокируется, а действие подписки может быть прекращено Исполнителем в одностороннем порядке без возврата оплаченных средств.</li>
|
|
64
|
-
</ul>
|
|
65
|
-
|
|
66
|
-
<h3>1.4. Порядок оплаты и предоставления доступа</h3>
|
|
67
|
-
<ul>
|
|
68
|
-
<li>Оплата тарифных планов производится в рублях РФ безналичным способом (банковские карты МИР, Visa, Mastercard, Система быстрых платежей) либо путём активации промокода / персонального VIP-токена.</li>
|
|
69
|
-
<li>Доступ к оплаченному тарифному плану активируется автоматически в момент получения подтверждения об успешной оплате (`succeeded`) от платёжной системы на срок оплаченного периода (30, 90 или 365 календарных дней).</li>
|
|
70
|
-
<li>Услуга предоставления доступа к PaaS/SaaS-платформе считается оказанной надлежащим образом с момента открытия технической возможности использования выбранного тарифа в учётной записи Пользователя.</li>
|
|
71
|
-
</ul>
|
|
72
|
-
|
|
73
|
-
<h3>1.5. Ограничение ответственности</h3>
|
|
74
|
-
<ul>
|
|
75
|
-
<li>Программное обеспечение предоставляется по принципу «как есть» (as is). Исполнитель не гарантирует достижения какой-либо положительной доходности при использовании аналитических расчётов программы. Тарифы различаются возможностями платформы (число счетов, лимиты, торговля с шортами) и не определяют доходность. Результаты исторических проверок (бэктестов) являются модельными расчётами, не гарантируют доходности в будущем; возможна потеря части или всего капитала.</li>
|
|
76
|
-
<li>Пользователь самостоятельно задаёт параметры стратегии, лимиты суммы, пороги дневного стопа и несёт полную имущественную ответственность за результаты сделок, совершаемых через подключённые им API-токены брокеров.</li>
|
|
77
|
-
</ul>
|
|
78
|
-
</section>
|
|
79
|
-
|
|
80
|
-
<section class="doc" id="privacy">
|
|
81
|
-
<h2>2. Политика обработки персональных данных (в соответствии с ФЗ № 152-ФЗ)</h2>
|
|
82
|
-
<h3>2.1. Какие данные обрабатываются</h3>
|
|
83
|
-
<ul>
|
|
84
|
-
<li>Адрес электронной почты (Email) и указанное Пользователем имя/псевдоним при регистрации и формировании кассового чека (54-ФЗ);</li>
|
|
85
|
-
<li>Идентификатор чата (chat_id) в мессенджерах Telegram и MAX при добровольной привязке уведомлений Пользователем;</li>
|
|
86
|
-
<li>Пользовательские API-токены брокеров — хранятся исключительно в зашифрованном виде по алгоритму <b>AES-256-GCM</b> и используются только для выполнения запросов по поручению Пользователя.</li>
|
|
87
|
-
</ul>
|
|
88
|
-
<h3>2.2. Цели обработки и защита</h3>
|
|
89
|
-
<ul>
|
|
90
|
-
<li>Предоставление доступа к личному кабинету, отправка уведомлений о рассчитанных заявках и исполнение требований законодательства РФ о применении контрольно-кассовой техники.</li>
|
|
91
|
-
<li>Персональные данные хранятся на территории Российской Федерации и не передаются третьим лицам, за исключением уполномоченного платёжного оператора в объёме, необходимом для проведения платежа и формирования кассового чека.</li>
|
|
92
|
-
<li>Пользователь вправе в любой момент удалить свои API-токены в разделе «Настройки → Брокерские счета» и запросить удаление учётной записи.</li>
|
|
93
|
-
</ul>
|
|
94
|
-
</section>
|
|
95
|
-
|
|
96
|
-
<section class="doc" id="risks">
|
|
97
|
-
<h2>3. Декларация о рисках</h2>
|
|
98
|
-
<p>Совершение операций с ценными бумагами и производными финансовыми инструментами на бирже сопряжено с высоким уровнем финансового риска и может привести к частичной или полной потере инвестированного капитала.</p>
|
|
99
|
-
<ul>
|
|
100
|
-
<li>Результаты пошаговых проверок на исторических данных и результаты на виртуальных счетах рассчитываются на прошлых котировках и <b>не гарантируют</b> получения аналогичных результатов в будущем.</li>
|
|
101
|
-
<li>При работе с реальным брокерским счётом возможны технические задержки связи, расхождения между расчётным и фактическим проскальзыванием в биржевом стакане, отказы API брокера и резкие ценовые разрывы (гэпы).</li>
|
|
102
|
-
<li>Используя сервис «НейроАльфа», Пользователь подтверждает, что осознаёт указанные риски и принимает все инвестиционные решения самостоятельно.</li>
|
|
103
|
-
</ul>
|
|
104
|
-
</section>
|
|
105
|
-
|
|
106
|
-
<section class="doc" id="requisites">
|
|
107
|
-
<h2>4. Правообладатель, Оператор платформы и контакты поддержки</h2>
|
|
108
|
-
<div id="legal-req-body">
|
|
109
|
-
<ul>
|
|
110
|
-
<li><b>Оператор платформы (Исполнитель):</b> ООО «БИС» («Бизнес Интер Софт», г. Рязань)</li>
|
|
111
|
-
<li><b>Автор и правообладатель ПО «НейроАльфа»:</b> Перепелкин Роман Александрович</li>
|
|
112
|
-
<li><b>Лицензионный статус:</b> Проприетарное программное обеспечение (Все права защищены). Копирование, декомпиляция, модификация и распространение без письменного согласия Правообладателя запрещены.</li>
|
|
113
|
-
</ul>
|
|
114
|
-
</div>
|
|
115
|
-
</section>
|
|
116
|
-
<div style="text-align:center;color:var(--muted);font-size:13px;margin-top:16px">© 2026 Автор: Перепелкин Р.А. · Оператор: ООО «БИС» («Бизнес Интер Софт», г. Рязань) · Все права защищены</div>
|
|
117
|
-
</div>
|
|
118
|
-
<script>
|
|
119
|
-
fetch('/api/public/legal').then((r) => r.json()).then((d) => {
|
|
120
|
-
const rows = [
|
|
121
|
-
`<li><b>Оператор платформы (Исполнитель):</b> ${d.ownerName || 'ООО «БИС» («Бизнес Интер Софт»)'}${d.status ? ` (${d.status})` : ''}</li>`,
|
|
122
|
-
`<li><b>Автор и правообладатель ПО:</b> ${d.authorName || 'Перепелкин Роман Александрович'}</li>`,
|
|
123
|
-
d.inn ? `<li><b>ИНН:</b> ${d.inn}</li>` : '',
|
|
124
|
-
d.ogrn ? `<li><b>ОГРН:</b> ${d.ogrn}</li>` : '',
|
|
125
|
-
d.email ? `<li><b>Email технической поддержки:</b> <a href="mailto:${d.email}">${d.email}</a></li>` : '',
|
|
126
|
-
d.phone ? `<li><b>Телефон:</b> ${d.phone}</li>` : '',
|
|
127
|
-
`<li><b>Местонахождение:</b> ${d.address || 'Российская Федерация, г. Рязань'}</li>`,
|
|
128
|
-
'<li><b>Правовой режим ПО:</b> Проприетарное программное обеспечение (Все права защищены)</li>',
|
|
129
|
-
].filter(Boolean).join('');
|
|
130
|
-
document.getElementById('legal-req-body').innerHTML = `<ul>${rows}</ul>`;
|
|
131
|
-
}).catch(() => {});
|
|
132
|
-
</script>
|
|
133
|
-
</body>
|
|
134
|
-
</html>
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
// Задать email и пароль главного администратора (пользователь №1) или добавить/обновить второго (запасного) администратора (--add).
|
|
3
|
-
// ADMIN_PASS='…' node scripts/set-admin-password.mjs --email admin@neuroalpha.odant.org
|
|
4
|
-
// ADMIN_PASS='…' node scripts/set-admin-password.mjs --add --email backup@neuroalpha.odant.org --name "Запасной админ"
|
|
5
|
-
// kubectl exec -it deploy/neuroalpha-central -- env ADMIN_PASS='…' node scripts/set-admin-password.mjs --email admin@…
|
|
6
|
-
// Пароль берётся из переменной ADMIN_PASS (чтобы не попадал в историю команд как аргумент).
|
|
7
|
-
import { db } from '../server/db.js';
|
|
8
|
-
import { hashPassword, ensureApiKey, createOrUpdateAdminUser } from '../server/auth/index.js';
|
|
9
|
-
|
|
10
|
-
const i = process.argv.indexOf('--email');
|
|
11
|
-
const iName = process.argv.indexOf('--name');
|
|
12
|
-
const isAdd = process.argv.includes('--add') || process.argv.includes('--second');
|
|
13
|
-
const email = String(i >= 0 ? process.argv[i + 1] : '').trim().toLowerCase();
|
|
14
|
-
const name = iName >= 0 ? String(process.argv[iName + 1] || '').trim() : '';
|
|
15
|
-
const pass = String(process.env.ADMIN_PASS || '');
|
|
16
|
-
if (!email.includes('@') || pass.length < 10) {
|
|
17
|
-
console.error('Укажите --email и пароль не короче 10 символов в переменной ADMIN_PASS');
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
if (isAdd) {
|
|
21
|
-
const u = createOrUpdateAdminUser({ email, password: pass, name: name || 'Администратор' });
|
|
22
|
-
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(u.id);
|
|
23
|
-
console.log(`✓ Запасной администратор (#${u.id}): ${u.email}. Прежние сессии этого аккаунта завершены.`);
|
|
24
|
-
process.exit(0);
|
|
25
|
-
}
|
|
26
|
-
db.prepare("UPDATE users SET email = ?, name = COALESCE(NULLIF(?, ''), name), pass_hash = ?, role = 'admin', plan = 'owner', plan_until = 0 WHERE id = 1").run(email, name, hashPassword(pass));
|
|
27
|
-
ensureApiKey(1);
|
|
28
|
-
db.prepare('DELETE FROM sessions WHERE user_id = 1').run();
|
|
29
|
-
console.log(`✓ Главный администратор (#1): ${email}. Все прежние сессии администратора завершены.`);
|
|
30
|
-
process.exit(0);
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
import crypto from 'node:crypto';
|
|
3
|
-
import { db, kvGet, kvSet } from '../db.js';
|
|
4
|
-
import { PLANS, encryptSecret, decryptSecret, getUserById, publicUser } from '../auth/index.js';
|
|
5
|
-
|
|
6
|
-
export function getBillingConfig() {
|
|
7
|
-
const saved = kvGet('yookassaConfig') || {};
|
|
8
|
-
const shopId = String(saved.shopId || process.env.YOOKASSA_SHOP_ID || '').trim();
|
|
9
|
-
const secretKey = decryptSecret(saved.secretKey || '') || String(process.env.YOOKASSA_SECRET_KEY || '').trim();
|
|
10
|
-
const sendReceipt = saved.sendReceipt !== undefined ? Boolean(saved.sendReceipt) : (process.env.YOOKASSA_RECEIPT === '1');
|
|
11
|
-
const vatCode = Number(saved.vatCode || process.env.YOOKASSA_VAT_CODE || 1);
|
|
12
|
-
return { shopId, secretKey, sendReceipt, vatCode, enabled: Boolean(shopId && secretKey) };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function publicBillingConfig() {
|
|
16
|
-
const c = getBillingConfig();
|
|
17
|
-
return { shopId: c.shopId, hasSecret: Boolean(c.secretKey), sendReceipt: c.sendReceipt, vatCode: c.vatCode, enabled: c.enabled, plans: PLANS };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function saveBillingConfig(input = {}) {
|
|
21
|
-
const prev = kvGet('yookassaConfig') || {};
|
|
22
|
-
const next = {
|
|
23
|
-
shopId: String(input.shopId ?? prev.shopId ?? '').trim(),
|
|
24
|
-
secretKey: input.clearSecret ? '' : (input.secretKey && String(input.secretKey).trim() ? encryptSecret(input.secretKey) : (prev.secretKey || '')),
|
|
25
|
-
sendReceipt: input.sendReceipt !== undefined ? Boolean(input.sendReceipt) : Boolean(prev.sendReceipt),
|
|
26
|
-
vatCode: Number(input.vatCode ?? prev.vatCode ?? 1) || 1,
|
|
27
|
-
};
|
|
28
|
-
kvSet('yookassaConfig', next);
|
|
29
|
-
return publicBillingConfig();
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function createYookassaPayment({ user, plan, months = 1, returnOrigin = 'http://localhost:3000' }) {
|
|
33
|
-
if (!user) throw new Error('Войдите в аккаунт для оформления подписки');
|
|
34
|
-
const planObj = PLANS[plan];
|
|
35
|
-
if (!planObj || planObj.priceRub <= 0) throw new Error('Выберите платный тариф (Инвестор или Профи)');
|
|
36
|
-
const m = Math.max(1, Math.min(12, Math.floor(Number(months) || 1)));
|
|
37
|
-
const discount = m >= 12 ? 0.8 : m >= 3 ? 0.9 : 1;
|
|
38
|
-
const amountRub = Math.round(planObj.priceRub * m * discount);
|
|
39
|
-
const cfg = getBillingConfig();
|
|
40
|
-
if (!cfg.enabled) throw new Error('Платёжный шлюз ЮKassa ещё не настроен администратором (укажите Shop ID и секретный ключ)');
|
|
41
|
-
|
|
42
|
-
const description = `Подписка «${planObj.name}» на ${m} мес. (${user.email})`;
|
|
43
|
-
const payload = {
|
|
44
|
-
amount: { value: amountRub.toFixed(2), currency: 'RUB' },
|
|
45
|
-
capture: true,
|
|
46
|
-
confirmation: {
|
|
47
|
-
type: 'redirect',
|
|
48
|
-
return_url: `${returnOrigin.replace(/\/+$/, '')}/landing?payment_check=1`,
|
|
49
|
-
},
|
|
50
|
-
description,
|
|
51
|
-
metadata: { userId: String(user.id), plan, months: String(m) },
|
|
52
|
-
};
|
|
53
|
-
if (cfg.sendReceipt && user.email) {
|
|
54
|
-
payload.receipt = {
|
|
55
|
-
customer: { email: user.email },
|
|
56
|
-
items: [{
|
|
57
|
-
description: description.slice(0, 128),
|
|
58
|
-
quantity: '1.00',
|
|
59
|
-
amount: { value: amountRub.toFixed(2), currency: 'RUB' },
|
|
60
|
-
vat_code: cfg.vatCode,
|
|
61
|
-
payment_mode: 'full_payment',
|
|
62
|
-
payment_subject: 'service',
|
|
63
|
-
}],
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
|
|
68
|
-
const r = await fetch('https://api.yookassa.ru/v3/payments', {
|
|
69
|
-
method: 'POST',
|
|
70
|
-
headers: {
|
|
71
|
-
Authorization: `Basic ${auth}`,
|
|
72
|
-
'Idempotence-Key': crypto.randomUUID(),
|
|
73
|
-
'Content-Type': 'application/json',
|
|
74
|
-
},
|
|
75
|
-
body: JSON.stringify(payload),
|
|
76
|
-
signal: AbortSignal.timeout(20000),
|
|
77
|
-
});
|
|
78
|
-
const j = await r.json().catch(() => ({}));
|
|
79
|
-
if (!r.ok || !j.id) throw new Error(j.description || `Ошибка ЮKassa: HTTP ${r.status}`);
|
|
80
|
-
|
|
81
|
-
db.prepare(`INSERT OR REPLACE INTO payments (id, user_id, plan, months, amount_rub, status, created_at, raw)
|
|
82
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(j.id, user.id, plan, m, amountRub, j.status || 'pending', Date.now(), JSON.stringify(j));
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
paymentId: j.id,
|
|
86
|
-
status: j.status,
|
|
87
|
-
amountRub,
|
|
88
|
-
confirmationUrl: j.confirmation?.confirmation_url || null,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function applyPaidSubscription(payRow, remoteObj) {
|
|
93
|
-
if (!payRow || payRow.status === 'succeeded') return;
|
|
94
|
-
const u = getUserById(payRow.user_id);
|
|
95
|
-
if (!u) return;
|
|
96
|
-
const baseTs = u.plan === payRow.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
|
|
97
|
-
const nextUntil = baseTs + (Number(payRow.months) || 1) * 30 * 86400000;
|
|
98
|
-
// Главный владелец и администраторы сохраняют полный тариф owner
|
|
99
|
-
if (u.id !== 1 && u.role !== 'admin') {
|
|
100
|
-
db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(payRow.plan, nextUntil, u.id);
|
|
101
|
-
}
|
|
102
|
-
db.prepare('UPDATE payments SET status = ?, paid_at = ?, raw = ? WHERE id = ?')
|
|
103
|
-
.run('succeeded', Date.now(), JSON.stringify(remoteObj || {}), payRow.id);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export async function verifyYookassaPayment(paymentId) {
|
|
107
|
-
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(String(paymentId));
|
|
108
|
-
if (!row) throw new Error('Платёж не найден');
|
|
109
|
-
if (row.status === 'succeeded') return { status: 'succeeded', user: publicUser(getUserById(row.user_id)) };
|
|
110
|
-
const cfg = getBillingConfig();
|
|
111
|
-
if (!cfg.enabled) throw new Error('ЮKassa не настроена');
|
|
112
|
-
const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
|
|
113
|
-
const r = await fetch(`https://api.yookassa.ru/v3/payments/${encodeURIComponent(row.id)}`, {
|
|
114
|
-
headers: { Authorization: `Basic ${auth}` },
|
|
115
|
-
signal: AbortSignal.timeout(15000),
|
|
116
|
-
});
|
|
117
|
-
const j = await r.json().catch(() => ({}));
|
|
118
|
-
if (!r.ok) throw new Error(j.description || `HTTP ${r.status}`);
|
|
119
|
-
if (j.status === 'succeeded' && j.paid) {
|
|
120
|
-
applyPaidSubscription(row, j);
|
|
121
|
-
} else if (j.status && j.status !== row.status) {
|
|
122
|
-
db.prepare('UPDATE payments SET status = ?, raw = ? WHERE id = ?').run(j.status, JSON.stringify(j), row.id);
|
|
123
|
-
}
|
|
124
|
-
return { status: j.status, user: publicUser(getUserById(row.user_id)) };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export async function verifyUserLatestPayment(userId) {
|
|
128
|
-
const row = db.prepare("SELECT * FROM payments WHERE user_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get(Number(userId));
|
|
129
|
-
if (!row) return { status: 'none', user: publicUser(getUserById(userId)) };
|
|
130
|
-
return verifyYookassaPayment(row.id);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function listAllPayments(limit = 100) {
|
|
134
|
-
return db.prepare(`SELECT p.id, p.user_id, u.email, p.plan, p.months, p.amount_rub, p.status, p.created_at, p.paid_at
|
|
135
|
-
FROM payments p LEFT JOIN users u ON u.id = p.user_id ORDER BY p.created_at DESC LIMIT ?`).all(limit);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function listUserPayments(userId, limit = 20) {
|
|
139
|
-
if (!userId) return [];
|
|
140
|
-
return db.prepare(`SELECT id, plan, months, amount_rub, status, created_at, paid_at
|
|
141
|
-
FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`).all(Number(userId), limit);
|
|
142
|
-
}
|
package/server/instance/agent.js
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
// Агент арендованной машины клиента: раз в минуту отправляет в центр техническое состояние
|
|
3
|
-
// и получает подписанную лицензию (тариф, срок, разрешение торговли). Без действующей лицензии
|
|
4
|
-
// новые заявки не отправляются; защитные стоп-заявки по открытым позициям продолжают работать
|
|
5
|
-
// (кроме аварийной остановки «halted» из центра).
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import os from 'node:os';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import { ROOT, DATA_DIR } from '../config.js';
|
|
10
|
-
import { db, kvGet, kvSet, userKvGet } from '../db.js';
|
|
11
|
-
import { IS_INSTANCE, IS_DESKTOP, INSTANCE_KIND } from '../role.js';
|
|
12
|
-
import { verifyToken } from './signing.js';
|
|
13
|
-
import { getIdentity, isActivated, saveDesktopIdentity, installId } from './identity.js';
|
|
14
|
-
|
|
15
|
-
const HEARTBEAT_MS = 60000;
|
|
16
|
-
const VERSION = (() => {
|
|
17
|
-
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || ''; } catch { return ''; }
|
|
18
|
-
})();
|
|
19
|
-
const startedAt = Date.now();
|
|
20
|
-
|
|
21
|
-
let license = null; // проверенная полезная нагрузка лицензии
|
|
22
|
-
let lastError = '';
|
|
23
|
-
const recentErrors = [];
|
|
24
|
-
|
|
25
|
-
function noteError(msg) {
|
|
26
|
-
const m = String(msg || '').slice(0, 200);
|
|
27
|
-
if (m && m !== lastError) console.warn(`⚠ Связь с центром: ${m}`);
|
|
28
|
-
lastError = m;
|
|
29
|
-
if (m) {
|
|
30
|
-
recentErrors.unshift(`${new Date().toISOString()} ${m}`);
|
|
31
|
-
recentErrors.length = Math.min(recentErrors.length, 5);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ---------- команды из мессенджера (кнопки «Исполнить / Отклонить»), доставленные в подписанной лицензии ----------
|
|
36
|
-
let commandHandler = null;
|
|
37
|
-
export function setInstanceCommandHandler(fn) { commandHandler = fn; }
|
|
38
|
-
const CMD_DONE_KEY = 'instanceCmdsDone';
|
|
39
|
-
const doneCmds = () => (kvGet(CMD_DONE_KEY) || []).filter((x) => Date.now() - x.ts < 3 * 3600000);
|
|
40
|
-
|
|
41
|
-
async function runCommands(cmds = []) {
|
|
42
|
-
if (!Array.isArray(cmds) || !cmds.length || !commandHandler) return;
|
|
43
|
-
const done = doneCmds();
|
|
44
|
-
const seen = new Set(done.map((x) => x.id));
|
|
45
|
-
for (const cmd of cmds.slice(0, 20)) {
|
|
46
|
-
if (!cmd?.id || seen.has(cmd.id)) continue;
|
|
47
|
-
// отмечаем до выполнения: повторная доставка той же команды не исполнит пакет дважды
|
|
48
|
-
done.push({ id: cmd.id, ts: Date.now() });
|
|
49
|
-
seen.add(cmd.id);
|
|
50
|
-
kvSet(CMD_DONE_KEY, done);
|
|
51
|
-
let reply;
|
|
52
|
-
try { reply = await commandHandler(cmd); } catch (e) { reply = `Ошибка выполнения команды: ${e.message}`; }
|
|
53
|
-
if (reply) relayNotifyToCentral(String(reply)).catch(() => {});
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function acceptLicenseToken(token, { runCmds = false } = {}) {
|
|
58
|
-
const p = verifyToken(token, { typ: 'license', aud: getIdentity().id });
|
|
59
|
-
license = p;
|
|
60
|
-
if (runCmds && p.cmds?.length) setImmediate(() => runCommands(p.cmds).catch(() => {}));
|
|
61
|
-
kvSet('instanceLicenseToken', token);
|
|
62
|
-
// локальный пользователь машины — клиент (не администратор платформы)
|
|
63
|
-
db.prepare("UPDATE users SET role = 'user', email = ?, name = ?, plan = ?, plan_until = ?, offer_accepted_at = ? WHERE id = 1")
|
|
64
|
-
.run(String(p.email || 'client@instance.local'), String(p.name || 'Клиент'), String(p.plan || 'none'), Number(p.planUntil) || 0, Number(p.offerAcceptedAt) || 0);
|
|
65
|
-
// ключ ИИ-прокси центра (провайдер «НейроАльфа Облако» берёт apiKey отсюда)
|
|
66
|
-
kvSet('cloudLicense', {
|
|
67
|
-
apiKey: p.apiKey || '',
|
|
68
|
-
cloudUrl: getIdentity().centralUrl,
|
|
69
|
-
email: p.email,
|
|
70
|
-
name: p.name,
|
|
71
|
-
plan: p.plan,
|
|
72
|
-
planName: p.planName,
|
|
73
|
-
planUntil: p.planUntil || 0,
|
|
74
|
-
verifiedAt: Date.now(),
|
|
75
|
-
});
|
|
76
|
-
return p;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function currentInstanceLicense() {
|
|
80
|
-
if (!license) return null;
|
|
81
|
-
if (!(license.exp * 1000 > Date.now())) return null;
|
|
82
|
-
return license;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Разрешения торговли. Вне режима instance ограничений нет.
|
|
86
|
-
export function tradingGate() {
|
|
87
|
-
if (!IS_INSTANCE) return { allowNew: true, allowProtective: true, reason: '' };
|
|
88
|
-
if (!isActivated()) return { allowNew: false, allowProtective: true, reason: 'Программа не активирована — введите код активации из личного кабинета' };
|
|
89
|
-
const lic = currentInstanceLicense();
|
|
90
|
-
if (!lic) return { allowNew: false, allowProtective: true, reason: 'Нет действующей лицензии центра — новые заявки приостановлены (защитные стопы работают)' };
|
|
91
|
-
if (lic.trading === 'halted') return { allowNew: false, allowProtective: false, reason: 'Торговля остановлена администратором платформы' };
|
|
92
|
-
if (lic.revoked) return { allowNew: false, allowProtective: true, reason: 'Аренда машины завершена — новые заявки не отправляются' };
|
|
93
|
-
if (lic.trading === 'paused') return { allowNew: false, allowProtective: true, reason: 'Торговля приостановлена администратором платформы (защитные стопы работают)' };
|
|
94
|
-
if (!lic.plan || lic.plan === 'none') return { allowNew: false, allowProtective: true, reason: 'Подписка не активна — новые заявки приостановлены' };
|
|
95
|
-
return { allowNew: true, allowProtective: true, reason: '' };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ---------- техническое состояние (без сумм, позиций, токенов и персональных данных) ----------
|
|
99
|
-
function collectStatus() {
|
|
100
|
-
let list = [];
|
|
101
|
-
try {
|
|
102
|
-
list = db.prepare('SELECT data FROM broker_accounts WHERE user_id = 1').all().map((r) => JSON.parse(r.data));
|
|
103
|
-
} catch {
|
|
104
|
-
const accs = userKvGet('brokerAccounts', [], 1);
|
|
105
|
-
list = Array.isArray(accs) ? accs : [];
|
|
106
|
-
}
|
|
107
|
-
const brokers = {};
|
|
108
|
-
for (const a of list) brokers[a.broker || 'unknown'] = (brokers[a.broker || 'unknown'] || 0) + 1;
|
|
109
|
-
const syncTs = list.map((a) => Number(a.lastSync?.at) || 0).filter(Boolean);
|
|
110
|
-
let dbMb = 0;
|
|
111
|
-
try { dbMb = Math.round(fs.statSync(path.join(DATA_DIR, 'trading.db')).size / 1048576); } catch { /* ignore */ }
|
|
112
|
-
const fcAt = Number(kvGet('lastForecastSnap')?.result?.at || 0);
|
|
113
|
-
const gate = tradingGate();
|
|
114
|
-
const lic = currentInstanceLicense();
|
|
115
|
-
return {
|
|
116
|
-
version: VERSION,
|
|
117
|
-
node: process.version,
|
|
118
|
-
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
|
119
|
-
rssMb: Math.round(process.memoryUsage().rss / 1048576),
|
|
120
|
-
dbMb,
|
|
121
|
-
accounts: list.length,
|
|
122
|
-
autoAccounts: list.filter((a) => a.mode === 'auto').length,
|
|
123
|
-
pausedAccounts: list.filter((a) => a.autoSchedule === false || a.mode === 'readonly').length,
|
|
124
|
-
haltedAccounts: list.filter((a) => a.haltedReason).length,
|
|
125
|
-
brokers,
|
|
126
|
-
lastSyncAgoSec: syncTs.length ? Math.round((Date.now() - Math.max(...syncTs)) / 1000) : null,
|
|
127
|
-
forecastAgoSec: fcAt ? Math.round((Date.now() - fcAt) / 1000) : null,
|
|
128
|
-
tradingAllowed: gate.allowNew,
|
|
129
|
-
tradingReason: gate.reason,
|
|
130
|
-
licenseLeftSec: lic ? Math.round(lic.exp - Date.now() / 1000) : 0,
|
|
131
|
-
errors: recentErrors.slice(0, 5),
|
|
132
|
-
host: os.hostname().slice(0, 60),
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function centralPost(pathname, body, timeoutMs = 20000, { withKey = true } = {}) {
|
|
137
|
-
const id = getIdentity();
|
|
138
|
-
const r = await fetch(`${id.centralUrl}${pathname}`, {
|
|
139
|
-
method: 'POST',
|
|
140
|
-
headers: { 'Content-Type': 'application/json', ...(withKey ? { Authorization: `Bearer ${id.key}` } : {}) },
|
|
141
|
-
body: JSON.stringify(body || {}),
|
|
142
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
143
|
-
});
|
|
144
|
-
const j = await r.json().catch(() => ({}));
|
|
145
|
-
if (!r.ok) throw Object.assign(new Error(j.error || `HTTP ${r.status}`), { status: r.status });
|
|
146
|
-
return j;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Адрес терминала на компьютере клиента (для входа из кабинета центра одноразовой ссылкой)
|
|
150
|
-
const localUrl = () => `http://localhost:${Number(process.env.PORT || 3000)}`;
|
|
151
|
-
|
|
152
|
-
let updateInfo = null; // { latestVersion, downloadUrl } — если центр сообщил о новой версии
|
|
153
|
-
|
|
154
|
-
export function instanceInfo() {
|
|
155
|
-
const id = getIdentity();
|
|
156
|
-
const lic = currentInstanceLicense();
|
|
157
|
-
return {
|
|
158
|
-
kind: INSTANCE_KIND,
|
|
159
|
-
activated: isActivated(),
|
|
160
|
-
id: id.id,
|
|
161
|
-
centralUrl: id.centralUrl,
|
|
162
|
-
version: VERSION,
|
|
163
|
-
licenseValidTill: lic ? lic.exp * 1000 : 0,
|
|
164
|
-
lastError,
|
|
165
|
-
update: updateInfo,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let hbBusy = false;
|
|
170
|
-
export async function heartbeatNow() {
|
|
171
|
-
if (hbBusy || !isActivated()) return license;
|
|
172
|
-
hbBusy = true;
|
|
173
|
-
try {
|
|
174
|
-
const j = await centralPost('/api/instance/heartbeat', {
|
|
175
|
-
status: collectStatus(),
|
|
176
|
-
installId: installId(),
|
|
177
|
-
kind: INSTANCE_KIND,
|
|
178
|
-
ack: doneCmds().map((x) => x.id),
|
|
179
|
-
...(IS_DESKTOP ? { localUrl: localUrl() } : {}),
|
|
180
|
-
});
|
|
181
|
-
if (!j.license) throw new Error('Центр не выдал лицензию');
|
|
182
|
-
acceptLicenseToken(j.license, { runCmds: true });
|
|
183
|
-
updateInfo = j.latestVersion && j.latestVersion !== VERSION ? { latestVersion: j.latestVersion, downloadUrl: j.downloadUrl || '' } : null;
|
|
184
|
-
if (lastError) console.log('✓ Связь с центром восстановлена, лицензия обновлена');
|
|
185
|
-
lastError = '';
|
|
186
|
-
} catch (e) {
|
|
187
|
-
// центр отверг ключ (отвязан, заменён или машина удалена) — лицензия отзывается сразу, без ожидания срока
|
|
188
|
-
if (e.status === 401 || e.status === 409) {
|
|
189
|
-
license = null;
|
|
190
|
-
kvSet('instanceLicenseToken', null);
|
|
191
|
-
}
|
|
192
|
-
noteError(e.message);
|
|
193
|
-
} finally {
|
|
194
|
-
hbBusy = false;
|
|
195
|
-
}
|
|
196
|
-
return license;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ---------- одноразовый вход с центра (SSO) ----------
|
|
200
|
-
const usedSso = new Map(); // jti -> exp
|
|
201
|
-
export function consumeSsoToken(token) {
|
|
202
|
-
const p = verifyToken(token, { typ: 'sso', aud: getIdentity().id, leewaySec: 30 });
|
|
203
|
-
const now = Math.floor(Date.now() / 1000);
|
|
204
|
-
for (const [k, exp] of usedSso) if (exp < now - 300) usedSso.delete(k);
|
|
205
|
-
if (!p.jti || usedSso.has(p.jti)) throw new Error('Ссылка входа уже использована — откройте терминал из кабинета ещё раз');
|
|
206
|
-
usedSso.set(p.jti, p.exp);
|
|
207
|
-
return p;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ---------- уведомления клиенту через центр (боты Telegram / MAX живут только в центре) ----------
|
|
211
|
-
// после уведомления с кнопками 15 минут связываемся с центром каждые 5 с, чтобы нажатие выполнилось быстро
|
|
212
|
-
let fastUntil = 0;
|
|
213
|
-
export async function relayNotifyToCentral(text, buttons = []) {
|
|
214
|
-
if (buttons?.length) fastUntil = Date.now() + 15 * 60000;
|
|
215
|
-
try {
|
|
216
|
-
const j = await centralPost('/api/instance/notify', { text: String(text || '').slice(0, 3500), buttons: buttons || [] }, 15000);
|
|
217
|
-
return { tg: Boolean(j.tg), max: Boolean(j.max) };
|
|
218
|
-
} catch (e) {
|
|
219
|
-
noteError(`уведомление: ${e.message}`);
|
|
220
|
-
return { tg: false, max: false };
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// ---------- активация установки на компьютере кодом из личного кабинета ----------
|
|
225
|
-
export async function activateDesktop(code) {
|
|
226
|
-
if (!IS_DESKTOP) throw new Error('Активация кодом доступна только для установки на компьютер');
|
|
227
|
-
const clean = String(code || '').trim().toUpperCase();
|
|
228
|
-
if (!/^[A-Z0-9-]{8,24}$/.test(clean)) throw new Error('Введите код активации из личного кабинета (вида NA-XXXX-XXXX)');
|
|
229
|
-
const j = await centralPost('/api/instance/activate', {
|
|
230
|
-
code: clean,
|
|
231
|
-
installId: installId(),
|
|
232
|
-
localUrl: localUrl(),
|
|
233
|
-
hostname: os.hostname().slice(0, 60),
|
|
234
|
-
version: VERSION,
|
|
235
|
-
}, 20000, { withKey: false });
|
|
236
|
-
if (!j.instanceId || !j.instanceKey) throw new Error('Центр не выдал ключ установки');
|
|
237
|
-
saveDesktopIdentity({ id: j.instanceId, key: j.instanceKey, centralUrl: getIdentity().centralUrl, publicKeyPem: j.publicKey || '' });
|
|
238
|
-
license = null;
|
|
239
|
-
await heartbeatNow();
|
|
240
|
-
if (!currentInstanceLicense()) throw new Error(lastError || 'Ключ получен, но лицензия не выдана — проверьте подписку в кабинете');
|
|
241
|
-
return instanceInfo();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export function startInstanceAgent() {
|
|
245
|
-
if (!IS_INSTANCE) return;
|
|
246
|
-
db.prepare("UPDATE users SET role = 'user' WHERE id = 1 AND role = 'admin'").run();
|
|
247
|
-
const saved = kvGet('instanceLicenseToken');
|
|
248
|
-
if (saved) {
|
|
249
|
-
try { acceptLicenseToken(saved); } catch (e) { noteError(`сохранённая лицензия: ${e.message}`); }
|
|
250
|
-
}
|
|
251
|
-
const loop = async () => {
|
|
252
|
-
await heartbeatNow().catch(() => {});
|
|
253
|
-
const t = setTimeout(loop, Date.now() < fastUntil ? 5000 : HEARTBEAT_MS);
|
|
254
|
-
t.unref?.();
|
|
255
|
-
};
|
|
256
|
-
loop();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export const INSTANCE_VERSION = VERSION;
|