neuroalpha 0.5.17 → 0.5.20

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/public/styles.css CHANGED
@@ -15,6 +15,9 @@
15
15
  --yellow: #d29922;
16
16
  --violet: #a371f7;
17
17
  --best-bg: #13241a;
18
+ --blocked-bg: rgba(244, 114, 182, 0.14);
19
+ --blocked-fg: #f472b6;
20
+ --blocked-border: rgba(244, 114, 182, 0.48);
18
21
  --flash-bg: #2a1f45;
19
22
  --chat-w: 38%;
20
23
  --accent-bg: rgba(88, 149, 255, 0.15);
@@ -36,6 +39,9 @@ body.light {
36
39
  --yellow: #9a6700;
37
40
  --violet: #8250df;
38
41
  --best-bg: #dcfce7;
42
+ --blocked-bg: #fce7f3;
43
+ --blocked-fg: #be185d;
44
+ --blocked-border: #f9a8d4;
39
45
  --flash-bg: #ede9fe;
40
46
  --accent-bg: rgba(36, 87, 214, 0.09);
41
47
  --hover-bg: #f0f3f6;
@@ -297,10 +303,13 @@ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line
297
303
  th { color: var(--muted); font-weight: 500; }
298
304
  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
299
305
  tr.best td { background: var(--best-bg); }
306
+ tr.fc-blocked td { background: var(--blocked-bg); }
307
+ tr.fc-blocked td:first-child { box-shadow: inset 3px 0 0 var(--blocked-fg); }
300
308
  .tag { font-size: 11px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
301
309
  .tag.ok { border-color: var(--green); color: var(--green); }
302
310
  .tag.no { border-color: var(--red); color: var(--red); }
303
311
  .tag.mid { border-color: var(--yellow); color: var(--yellow); }
312
+ .tag.blocked { border-color: var(--blocked-border); color: var(--blocked-fg); background: var(--blocked-bg); font-weight: 500; }
304
313
  .stab-box { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; margin: 8px 0 10px; font-size: 13px; }
305
314
  .stab-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
306
315
  .stab-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
@@ -583,7 +592,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
583
592
  background: var(--accent);
584
593
  border-radius: 4px;
585
594
  }
586
-
587
- /* Прогноз: две таблицы с общей сеткой колонок */
588
- .fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
595
+
596
+ /* Прогноз: две таблицы с общей сеткой колонок */
597
+ .fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
589
598
 
@@ -0,0 +1,30 @@
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);
@@ -6,13 +6,13 @@ import { ROOT, strategyDefaults } from '../config.js';
6
6
  import { activeModelInfo } from '../llm/client.js';
7
7
  import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
8
8
  import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
9
- import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
- import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments } from '../billing/yookassa.js';
9
+ import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, parseAllCookieValues, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
+ import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
11
11
  import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
12
12
  import { PARAMS } from '../params-meta.js';
13
13
  import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
14
14
  import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
15
- import { forecastNow, TIER1_LIQUID_SYMBOLS } from '../model/forecast.js';
15
+ import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
16
16
  import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
17
17
  import { ensureDividends } from '../data/moex.js';
18
18
  import { live } from '../model/live.js';
@@ -25,7 +25,7 @@ import { isCrossOrigin } from '../security.js';
25
25
  import { IS_CENTRAL, IS_INSTANCE, CENTRAL_URL, clientIp, isHttpsRequest } from '../role.js';
26
26
  import { consumeSsoToken } from '../instance/agent.js';
27
27
  import { IS_DESKTOP } from '../role.js';
28
- import { listInstances, createInstance, rotateInstanceKey, setInstanceTrading, setInstanceRevoked, deleteInstance, instanceByKey, instanceHeartbeat, cabinetInstances, ssoRedirectUrl, relayInstanceNotify, centralPublicKeyPem, createDesktopActivationCode, activateDesktopInstall, unbindInstall } from '../instance/central.js';
28
+ import { listInstances, createInstance, rotateInstanceKey, setInstanceTrading, setInstanceRevoked, deleteInstance, instanceByKey, instanceHeartbeat, cabinetInstances, ensureUserCloudInstance, ssoRedirectUrl, relayInstanceNotify, centralPublicKeyPem, createDesktopActivationCode, activateDesktopInstall, unbindInstall } from '../instance/central.js';
29
29
  import { activateDesktop, instanceInfo } from '../instance/agent.js';
30
30
  import { isActivated } from '../instance/identity.js';
31
31
 
@@ -73,8 +73,16 @@ export function createRouter() {
73
73
  const secure = isHttpsRequest(res.req) ? '; Secure' : '';
74
74
  res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
75
75
  };
76
+ const isRuSegmentRequest = (req) => {
77
+ if (process.env.ALLOW_FOREIGN_AUTH === '1') return false;
78
+ const country = String(req.headers?.['cf-ipcountry'] || req.headers?.['x-country-code'] || req.headers?.['x-geo-country'] || '').trim().toUpperCase();
79
+ if (country) return country === 'RU';
80
+ const lang = String(req.headers?.['accept-language'] || '').toLowerCase();
81
+ if (lang && !lang.includes('ru')) return false;
82
+ return true;
83
+ };
76
84
  r.get('/auth/me', (req, res) => res.json({
77
- user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [] } : null,
85
+ user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
78
86
  downloads: IS_CENTRAL ? {
79
87
  windows: process.env.DESKTOP_DOWNLOAD_WINDOWS || '',
80
88
  linux: process.env.DESKTOP_DOWNLOAD_LINUX || '',
@@ -83,6 +91,7 @@ export function createRouter() {
83
91
  role: IS_INSTANCE ? 'instance' : IS_CENTRAL ? 'central' : 'dev',
84
92
  centralUrl: IS_INSTANCE ? CENTRAL_URL : '',
85
93
  cloudMode: process.env.CLOUD_MODE === '1',
94
+ isRuSegment: isRuSegmentRequest(req),
86
95
  plans: PLANS,
87
96
  notify: publicNotifyConfig(),
88
97
  oauth: publicOAuthConfig(),
@@ -154,8 +163,9 @@ export function createRouter() {
154
163
  res.json({ user: publicUser(u), token });
155
164
  }));
156
165
  r.post('/auth/logout', (req, res) => {
157
- const cookies = parseCookies(req.headers?.cookie);
158
- if (cookies.sid) deleteSession(cookies.sid);
166
+ for (const tok of parseAllCookieValues(req.headers?.cookie, 'sid')) {
167
+ if (tok && tok !== 'logged_out') deleteSession(tok);
168
+ }
159
169
  setCookie(res, 'logged_out', 365 * 86400);
160
170
  res.json({ ok: true, ...(IS_INSTANCE ? { redirect: CENTRAL_URL } : {}) });
161
171
  });
@@ -221,10 +231,11 @@ export function createRouter() {
221
231
  if (!sent.tg && !sent.max) throw new Error('Сначала привяжите Telegram или MAX по вашему коду привязки');
222
232
  res.json({ ok: true, ...sent });
223
233
  }));
224
- r.post('/auth/redeem-promo', wrap((req, res) => {
234
+ r.post('/auth/redeem-promo', wrap(async (req, res) => {
225
235
  if (!req.user) throw new Error('Войдите в аккаунт для активации кода или VIP-токена');
226
236
  const user = redeemPromoCode(req.user.id, req.body?.code);
227
- res.json({ user });
237
+ if (IS_CENTRAL) await ensureUserCloudInstance(req.user.id).catch(() => {});
238
+ res.json({ user: { ...user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } });
228
239
  }));
229
240
  r.post('/auth/accept-offer', wrap((req, res) => {
230
241
  if (!req.user) throw new Error('Войдите в аккаунт');
@@ -277,12 +288,25 @@ export function createRouter() {
277
288
  }));
278
289
  r.post('/billing/verify', wrap(async (req, res) => {
279
290
  if (!req.user) throw new Error('Войдите в аккаунт');
280
- if (req.body?.paymentId) return res.json(await verifyYookassaPayment(req.body.paymentId));
281
- return res.json(await verifyUserLatestPayment(req.user.id));
291
+ const out = req.body?.paymentId
292
+ ? await verifyYookassaPayment(req.body.paymentId)
293
+ : await verifyUserLatestPayment(req.user.id);
294
+ if (IS_CENTRAL && out?.status === 'succeeded') {
295
+ await ensureUserCloudInstance(req.user.id).catch(() => {});
296
+ }
297
+ res.json({
298
+ ...out,
299
+ user: out?.user ? { ...out.user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
300
+ });
282
301
  }));
283
302
  r.post('/billing/yookassa/webhook', wrap(async (req, res) => {
284
303
  const pid = req.body?.object?.id;
285
- if (pid) await verifyYookassaPayment(pid).catch(() => {});
304
+ if (pid) {
305
+ const out = await verifyYookassaPayment(pid).catch(() => null);
306
+ if (IS_CENTRAL && out?.status === 'succeeded' && out?.user?.id) {
307
+ await ensureUserCloudInstance(out.user.id).catch(() => {});
308
+ }
309
+ }
286
310
  res.json({ ok: true });
287
311
  }));
288
312
  r.post('/notify/max/webhook', wrap(async (req, res) => {
@@ -393,9 +417,15 @@ export function createRouter() {
393
417
  requireAdmin(req);
394
418
  res.json({ promos: createPromoCode(req.body || {}) });
395
419
  }));
396
- r.post('/admin/user-plan', wrap((req, res) => {
420
+ r.post('/admin/user-plan', wrap(async (req, res) => {
421
+ requireAdmin(req);
422
+ const user = setUserPlan(req.body?.userId, req.body?.plan, req.body?.days || 30);
423
+ if (IS_CENTRAL) await ensureUserCloudInstance(req.body?.userId).catch(() => {});
424
+ res.json({ user, users: listAllUsers() });
425
+ }));
426
+ r.post('/admin/create-admin', wrap((req, res) => {
397
427
  requireAdmin(req);
398
- res.json({ user: setUserPlan(req.body?.userId, req.body?.plan, req.body?.days || 30), users: listAllUsers() });
428
+ res.json({ user: createOrUpdateAdminUser(req.body || {}), users: listAllUsers() });
399
429
  }));
400
430
  r.post('/admin/billing', wrap((req, res) => {
401
431
  requireAdmin(req);
@@ -636,6 +666,7 @@ export function createRouter() {
636
666
  const vocabHit = vocabList.find((v) => v.symbol === sym);
637
667
  const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
638
668
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
669
+ const tierMult = symbolEdgeMult(sym);
639
670
 
640
671
  // 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
641
672
  const nowTs = Date.now();
@@ -727,8 +758,10 @@ export function createRouter() {
727
758
  yearChangePct,
728
759
  tier: isTier1 ? 1 : 2,
729
760
  tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
730
- buyThresholdPct: isTier1 ? Math.max(fc?.buyThresholdPct || 0.30, 0.30) : Math.max(fc?.buyThresholdPct || 0.30, 0.35),
731
- shortThresholdPct: isTier1 ? Math.max(fc?.shortThresholdPct || 0.31, 0.31) : Math.max(fc?.shortThresholdPct || 0.31, 0.36),
761
+ edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
762
+ sigmaPct: fcRow?.sigmaPct ?? null,
763
+ buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.413) * tierMult, 0.30), 3),
764
+ shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.516) * tierMult, 0.31), 3),
732
765
  shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
733
766
  specs: {
734
767
  lotSize: sp.lotSize,
@@ -748,6 +781,12 @@ export function createRouter() {
748
781
  signal: Boolean(fcRow.signal),
749
782
  shortSignal: Boolean(fcRow.shortSignal),
750
783
  shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
784
+ shortBlockedBySqueeze: Boolean(fcRow.shortBlockedBySqueeze),
785
+ buyBlockedByDip: Boolean(fcRow.buyBlockedByDip),
786
+ buyBlockedByDiv: fcRow.buyBlockedByDiv || null,
787
+ shortBlockedByDiv: fcRow.shortBlockedByDiv || null,
788
+ buyBlockedByNews: fcRow.buyBlockedByNews || null,
789
+ shortBlockedByNews: fcRow.shortBlockedByNews || null,
751
790
  selfPct: fcRow.selfPct ?? 0,
752
791
  influences: (fcRow.influences || []).map((inf) => {
753
792
  const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
@@ -281,7 +281,7 @@ export function effectivePlan(user) {
281
281
  if (process.env.LOCAL_STRICT_LICENSE === '1') return PLANS.none;
282
282
  return PLANS.owner;
283
283
  }
284
- if (user.role === 'admin' || user.plan === 'owner') return PLANS[user.plan] || PLANS.owner;
284
+ if (user.id === 1 || user.role === 'admin' || user.plan === 'owner') return PLANS.owner;
285
285
  const p = PLANS[user.plan] || PLANS.none;
286
286
  if (p.id !== 'none' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.none;
287
287
  return p;
@@ -360,8 +360,20 @@ export function parseCookies(cookieHeader = '') {
360
360
  return out;
361
361
  }
362
362
 
363
+ export function parseAllCookieValues(cookieHeader = '', key = 'sid') {
364
+ const vals = [];
365
+ for (const part of String(cookieHeader || '').split(';')) {
366
+ const i = part.indexOf('=');
367
+ if (i > 0 && part.slice(0, i).trim() === key) {
368
+ vals.push(decodeURIComponent(part.slice(i + 1).trim()));
369
+ }
370
+ }
371
+ return vals;
372
+ }
373
+
363
374
  export function userFromRequest(req) {
364
375
  const cookies = parseCookies(req.headers?.cookie);
376
+ const allSids = parseAllCookieValues(req.headers?.cookie, 'sid');
365
377
  const authHeader = String(req.headers?.authorization || '');
366
378
  const apiKeyHeader = String(req.headers?.['x-api-key'] || '').trim();
367
379
  const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
@@ -370,8 +382,8 @@ export function userFromRequest(req) {
370
382
  const byKey = getUserByApiKey(apiCandidate);
371
383
  if (byKey) return byKey;
372
384
  }
373
- const token = cookies.sid || bearer;
374
- if (token) {
385
+ const candidates = [...allSids.filter((s) => s && s !== 'logged_out'), ...(bearer ? [bearer] : [])];
386
+ for (const token of candidates) {
375
387
  const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
376
388
  if (sess) {
377
389
  const u = getUserById(sess.user_id);
@@ -381,7 +393,8 @@ export function userFromRequest(req) {
381
393
  // В локальном режиме (без CLOUD_MODE=1) владелец (id = 1) открывается без входа только с этого компьютера
382
394
  // Установка на компьютер клиента после активации — так же, без пароля только с этого компьютера
383
395
  const localTrusted = !IS_INSTANCE || (IS_DESKTOP && isActivated());
384
- if (process.env.CLOUD_MODE !== '1' && localTrusted && cookies.sid !== 'logged_out' && isLoopbackRequest(req)) {
396
+ const explicitlyLoggedOut = cookies.sid === 'logged_out' || allSids.includes('logged_out');
397
+ if (process.env.CLOUD_MODE !== '1' && localTrusted && !explicitlyLoggedOut && isLoopbackRequest(req)) {
385
398
  return getUserById(1);
386
399
  }
387
400
  return null;
@@ -445,9 +458,36 @@ export function listAllUsers() {
445
458
 
446
459
  export function setUserPlan(userId, plan, days = 30) {
447
460
  if (!(plan in PLANS)) throw new Error(`Неизвестный тариф: ${plan}`);
461
+ const uid = Number(userId);
462
+ if (!uid) throw new Error('Некорректный ID пользователя');
448
463
  const until = (plan === 'none' || plan === 'owner') ? 0 : Date.now() + Math.max(1, Number(days) || 30) * 86400000;
449
- db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(plan, until, Number(userId));
450
- return publicUser(getUserById(userId));
464
+ // Тариф «Хозяин» (owner) даёт права администратора (role = 'admin'); главный владелец (id = 1) всегда сохраняет роль admin
465
+ const role = (plan === 'owner' || uid === 1) ? 'admin' : 'user';
466
+ db.prepare('UPDATE users SET plan = ?, plan_until = ?, role = ? WHERE id = ?').run(plan, until, role, uid);
467
+ return publicUser(getUserById(uid));
468
+ }
469
+
470
+ export function createOrUpdateAdminUser({ email, password, name }) {
471
+ const cleanEmail = String(email || '').trim().toLowerCase();
472
+ if (!cleanEmail || !cleanEmail.includes('@')) throw new Error('Укажите корректный email администратора');
473
+ const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(cleanEmail);
474
+ const cleanPass = String(password || '').trim();
475
+ if (existing) {
476
+ if (cleanPass && cleanPass.length < 6) throw new Error('Пароль должен быть не короче 6 символов');
477
+ const nextHash = cleanPass ? hashPassword(cleanPass) : existing.pass_hash;
478
+ const nextName = String(name || '').trim() || existing.name || 'Администратор';
479
+ db.prepare("UPDATE users SET name = ?, pass_hash = ?, role = 'admin', plan = 'owner', plan_until = 0 WHERE id = ?")
480
+ .run(nextName, nextHash, existing.id);
481
+ ensureApiKey(existing.id);
482
+ return publicUser(getUserById(existing.id));
483
+ }
484
+ if (cleanPass.length < 6) throw new Error('Для нового администратора укажите пароль не короче 6 символов');
485
+ const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
486
+ const apiKey = generateApiKey();
487
+ const cleanName = String(name || '').trim() || 'Администратор';
488
+ const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, offer_accepted_at, created_at)
489
+ VALUES (?, ?, ?, 'admin', 'owner', 0, ?, ?, ?, ?)`).run(cleanEmail, cleanName, hashPassword(cleanPass), code, apiKey, Date.now(), Date.now());
490
+ return publicUser(getUserById(Number(r.lastInsertRowid)));
451
491
  }
452
492
 
453
493
  // ---------- промокоды и персональные VIP-токены ----------
@@ -480,7 +520,11 @@ export function redeemPromoCode(userId, rawCode) {
480
520
  const u = getUserById(userId);
481
521
  const baseTs = u.plan === promo.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
482
522
  const nextUntil = baseTs + promo.days * 86400000;
483
- db.prepare('UPDATE users SET plan = ?, plan_until = ?, offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(promo.plan, nextUntil, Date.now(), u.id);
523
+ if (u.id === 1 || u.role === 'admin') {
524
+ db.prepare('UPDATE users SET offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(Date.now(), u.id);
525
+ } else {
526
+ db.prepare('UPDATE users SET plan = ?, plan_until = ?, offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(promo.plan, nextUntil, Date.now(), u.id);
527
+ }
484
528
  db.prepare('UPDATE promocodes SET used_count = used_count + 1 WHERE code = ?').run(promo.code);
485
529
  db.prepare('INSERT INTO promocode_uses (code, user_id, used_at) VALUES (?, ?, ?)').run(promo.code, u.id, Date.now());
486
530
  return publicUser(getUserById(userId));
@@ -518,11 +562,11 @@ export function authMiddleware(req, res, next) {
518
562
 
519
563
  // ---------- OAuth 2.0 / Социальный вход: Яндекс, Google, Сбер ID, Т-Банк ID, VK ID ----------
520
564
  export const OAUTH_PROVIDERS = [
521
- { id: 'yandex', name: 'Яндекс ID', authUrl: 'https://oauth.yandex.ru/authorize', tokenUrl: 'https://oauth.yandex.ru/token', userUrl: 'https://login.yandex.ru/info?format=json', scope: 'login:email login:info' },
522
- { id: 'google', name: 'Google', authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', scope: 'openid email profile' },
523
- { id: 'sber', name: 'Сбер ID', authUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oauth', tokenUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oidc', userUrl: 'https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo', scope: 'openid name email' },
524
- { id: 'tbank', name: 'T-ID (Т-Банк)', authUrl: 'https://id.tbank.ru/auth/authorize', tokenUrl: 'https://id.tbank.ru/auth/token', userUrl: 'https://id.tbank.ru/userinfo/userinfo', scope: 'openid profile email' },
525
- { id: 'vk', name: 'VK ID', authUrl: 'https://oauth.vk.com/authorize', tokenUrl: 'https://oauth.vk.com/access_token', userUrl: 'https://api.vk.com/method/users.get?v=5.199', scope: 'email' },
565
+ { id: 'yandex', name: 'Яндекс ID', foreign: false, authUrl: 'https://oauth.yandex.ru/authorize', tokenUrl: 'https://oauth.yandex.ru/token', userUrl: 'https://login.yandex.ru/info?format=json', scope: 'login:email login:info' },
566
+ { id: 'google', name: 'Google', foreign: true, authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', scope: 'openid email profile' },
567
+ { id: 'sber', name: 'Сбер ID', foreign: false, authUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oauth', tokenUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oidc', userUrl: 'https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo', scope: 'openid name email' },
568
+ { id: 'tbank', name: 'T-ID (Т-Банк)', foreign: false, authUrl: 'https://id.tbank.ru/auth/authorize', tokenUrl: 'https://id.tbank.ru/auth/token', userUrl: 'https://id.tbank.ru/userinfo/userinfo', scope: 'openid profile email' },
569
+ { id: 'vk', name: 'VK ID', foreign: false, authUrl: 'https://oauth.vk.com/authorize', tokenUrl: 'https://oauth.vk.com/access_token', userUrl: 'https://api.vk.com/method/users.get?v=5.199', scope: 'email' },
526
570
  ];
527
571
 
528
572
  export function getOAuthConfig() {
@@ -533,7 +577,7 @@ export function getOAuthConfig() {
533
577
  const cur = saved[p.id] || {};
534
578
  const clientId = String(cur.clientId || process.env[`${envPrefix}CLIENT_ID`] || '').trim();
535
579
  const clientSecret = decryptSecret(cur.clientSecret || '') || String(process.env[`${envPrefix}CLIENT_SECRET`] || '').trim();
536
- out[p.id] = { id: p.id, name: p.name, clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
580
+ out[p.id] = { id: p.id, name: p.name, foreign: Boolean(p.foreign), clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
537
581
  }
538
582
  return out;
539
583
  }
@@ -543,6 +587,7 @@ export function publicOAuthConfig() {
543
587
  return Object.fromEntries(OAUTH_PROVIDERS.map((p) => [p.id, {
544
588
  id: p.id,
545
589
  name: p.name,
590
+ foreign: Boolean(p.foreign),
546
591
  clientId: cfg[p.id].clientId,
547
592
  hasSecret: Boolean(cfg[p.id].clientSecret),
548
593
  enabled: cfg[p.id].enabled,