work-license-server 1.0.3 → 1.0.4

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/src/server.js CHANGED
@@ -19,8 +19,11 @@
19
19
  import http from 'node:http';
20
20
  import fs from 'node:fs';
21
21
  import crypto from 'node:crypto';
22
+ import { fileURLToPath } from 'node:url';
22
23
  import {
23
24
  PAK_PRODUCT,
25
+ BASE_LICENSE_PRODUCT,
26
+ ADDITIONAL_PRODUCTS,
24
27
  SLA_TIERS,
25
28
  SEAT_VOLUME_DISCOUNTS,
26
29
  calculateSeatVolumeDiscount,
@@ -32,12 +35,16 @@ import {
32
35
  calculatePartnerDiscount,
33
36
  calculatePartnerEconomics,
34
37
  calculateRegionPartnerQuota,
38
+ PUBLIC_OFERTA,
35
39
  } from './catalog.js';
36
40
  import { LicenseStore } from './store.js';
37
41
  import { YooKassaService } from './yookassa.js';
38
42
  import { SupportBotsHub } from './bots.js';
39
43
  import { LicenseServerAiAgent } from './agent.js';
40
44
  import { renderLandingPage, renderSandboxPaymentPage } from './landing.js';
45
+ import { renderAdminPage } from './admin.js';
46
+
47
+ const DEFAULT_DATA_DIR = fileURLToPath(new URL('../data', import.meta.url));
41
48
 
42
49
  async function readJsonBody(req, maxBytes = 1024 * 1024) {
43
50
  const chunks = [];
@@ -72,16 +79,17 @@ const STATIC_ASSETS = Object.freeze({
72
79
  '/assets/russia-regions.json': { file: 'russia-regions.json', type: 'application/json; charset=utf-8' },
73
80
  '/assets/landing.css': { file: 'landing.css', type: 'text/css; charset=utf-8' },
74
81
  '/assets/landing.js': { file: 'landing.js', type: 'text/javascript; charset=utf-8' },
82
+ '/assets/admin.js': { file: 'admin.js', type: 'text/javascript; charset=utf-8' },
75
83
  });
76
84
  const assetCache = new Map();
77
85
  let assetVersionCache = '';
78
86
 
79
- /** Версия статики для сброса кэша браузера: короткий хеш стилей и скрипта витрины. */
87
+ /** Версия статики для сброса кэша браузера: короткий хеш стилей и скриптов витрины/админки. */
80
88
  function assetVersion() {
81
89
  if (assetVersionCache && !process.env.WORK_DEV)
82
90
  return assetVersionCache;
83
91
  const hash = crypto.createHash('sha1');
84
- for (const file of ['landing.css', 'landing.js'])
92
+ for (const file of ['landing.css', 'landing.js', 'admin.js'])
85
93
  hash.update(fs.readFileSync(new URL(file, ASSETS_DIR)));
86
94
  assetVersionCache = hash.digest('hex').slice(0, 10);
87
95
  return assetVersionCache;
@@ -109,7 +117,11 @@ function sendHtml(res, status, html) {
109
117
  }
110
118
 
111
119
  export function createLicenseServerApp(options = {}) {
112
- const store = options.store || new LicenseStore({ dataDir: options.dataDir || './data' });
120
+ const store = options.store || new LicenseStore({
121
+ dataDir: options.dataDir || DEFAULT_DATA_DIR,
122
+ adminLogin: options.adminLogin,
123
+ adminPassword: options.adminPassword,
124
+ });
113
125
  let bots = null;
114
126
  const yookassa = options.yookassa || new YooKassaService({
115
127
  store,
@@ -137,13 +149,51 @@ export function createLicenseServerApp(options = {}) {
137
149
  fetchImpl: options.fetchImpl,
138
150
  });
139
151
 
152
+ function extractBearerToken(req, body = null, url = null) {
153
+ const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
154
+ const xAdmin = String(req.headers['x-admin-token'] || req.headers['x-admin-key'] || '').trim();
155
+ return authHeader || xAdmin || body?.adminToken || body?.sessionKey || url?.searchParams?.get('sessionKey') || '';
156
+ }
157
+
158
+ function ensureCustomerOferta(req, body = {}) {
159
+ const tok = extractBearerToken(req, body);
160
+ let user = store.findUserBySessionKey(tok);
161
+ if (user && user.ofertaAccepted)
162
+ return user;
163
+
164
+ // Если в запросе переданы реквизиты и явное согласие с офертой (ofertaAccepted: true) — регистрируем и подписываем ПЭП
165
+ if (body.ofertaAccepted === true) {
166
+ const email = body.email || body.customer?.email || 'client@odant-work.ru';
167
+ const companyName = body.companyName || body.customer?.name || 'Организация ODANT-WORK';
168
+ const inn = body.inn || body.customer?.inn || '';
169
+ user = store.registerOrLoginUser({
170
+ email,
171
+ companyName,
172
+ inn,
173
+ name: body.signerName || body.contactPerson || companyName,
174
+ licenseId: body.licenseId || '',
175
+ ofertaAccepted: true,
176
+ });
177
+ return user;
178
+ }
179
+
180
+ // Если это прямой M2M-запрос с привязанного ПАК WORK (переданы hwid и nodeFingerprint)
181
+ if (body.hwid && body.nodeFingerprint)
182
+ return { hwid: body.hwid, ofertaAccepted: true };
183
+
184
+ const err = new Error('Любые действия по заказам, оплате и резервированию франшизы доступны только после регистрации и подписания публичной оферты ODANT-WORK (ПЭП)');
185
+ err.statusCode = 403;
186
+ err.code = 'OFERTA_REQUIRED';
187
+ throw err;
188
+ }
189
+
140
190
  async function handler(req, res) {
141
191
  try {
142
192
  if (req.method === 'OPTIONS') {
143
193
  res.writeHead(204, {
144
194
  'Access-Control-Allow-Origin': '*',
145
195
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
146
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
196
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Admin-Token, X-Admin-Key',
147
197
  });
148
198
  res.end();
149
199
  return;
@@ -155,44 +205,77 @@ export function createLicenseServerApp(options = {}) {
155
205
  const url = new URL(req.url || '/', reqOrigin);
156
206
  const pathname = url.pathname;
157
207
 
158
- // 1. Главная страница лендинга work.odant.org и панель администратора (/admin)
159
- if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html' || pathname === '/admin')) {
208
+ // 1. Главная страница публичной витрины work.odant.org (без админки)
209
+ if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) {
160
210
  return sendHtml(res, 200, renderLandingPage({
161
211
  publicKey: store.authorityKeys.publicKey,
162
212
  regions: store.listRegionsWithQuotas(),
163
213
  assetVersion: assetVersion(),
164
- initialStage: pathname === '/admin' ? 'admin' : '',
165
214
  }));
166
215
  }
167
216
 
168
- // 1.1 Статика лендинга: логотип ODANT, стили, клиентский скрипт и контуры субъектов РФ
217
+ // 1.0 Отдельная страница защищённой админ-панели (/admin)
218
+ if (req.method === 'GET' && (pathname === '/admin' || pathname === '/admin/')) {
219
+ return sendHtml(res, 200, renderAdminPage({
220
+ publicKey: store.authorityKeys.publicKey,
221
+ assetVersion: assetVersion(),
222
+ }));
223
+ }
224
+
225
+ // 1.1 Статика: логотип ODANT, стили, клиентские скрипты и контуры субъектов РФ
169
226
  if (req.method === 'GET' && STATIC_ASSETS[pathname]) {
170
227
  return sendStaticAsset(res, STATIC_ASSETS[pathname]);
171
228
  }
172
229
 
173
- // 2. Каталог ПАК, тарифов SLA и партнёрской франшизы
230
+ // 2. Каталог ПАК, отдельной базовой лицензии (4 млн ₽), тарифов SLA, прочих товаров, партнёрской франшизы и оферты
174
231
  if (req.method === 'GET' && pathname === '/api/catalog') {
175
232
  return sendJson(res, 200, {
176
233
  product: PAK_PRODUCT,
234
+ baseLicenseProduct: BASE_LICENSE_PRODUCT,
235
+ additionalProducts: ADDITIONAL_PRODUCTS,
177
236
  tiers: Object.values(SLA_TIERS),
178
237
  volumeDiscounts: SEAT_VOLUME_DISCOUNTS,
179
238
  franchiseProgram: FRANCHISE_PROGRAM,
180
239
  regions: store.listRegionsWithQuotas(),
181
240
  trainingTracks: PARTNER_TRAINING_TRACKS,
241
+ oferta: PUBLIC_OFERTA,
182
242
  authorityPublicKey: store.authorityKeys.publicKey,
183
243
  });
184
244
  }
185
245
 
186
- // 2.0 Вход / регистрация пользователя (для доступа к разделу докупки пользовательских лицензий)
246
+ if (req.method === 'GET' && pathname === '/api/oferta') {
247
+ return sendJson(res, 200, PUBLIC_OFERTA);
248
+ }
249
+
250
+ // 2.0 Вход / регистрация пользователя и подписание публичной оферты ПЭП
187
251
  if (req.method === 'POST' && (pathname === '/api/auth/login' || pathname === '/api/auth/register')) {
188
252
  const body = await readJsonBody(req);
189
- const user = store.registerOrLoginUser(body);
253
+ const user = store.registerOrLoginUser({
254
+ ...body,
255
+ hubUrl: body.hubUrl || reqOrigin,
256
+ });
257
+ return sendJson(res, 200, user);
258
+ }
259
+
260
+ // 2.0.1 Генерация или перевыпуск токена владельца (с зашитым URL сервера поддержки) в личном кабинете
261
+ if (req.method === 'POST' && pathname === '/api/cabinet/token') {
262
+ const body = await readJsonBody(req);
263
+ const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
264
+ const result = store.generateUserHubToken(key, {
265
+ hubUrl: body.hubUrl || reqOrigin,
266
+ });
267
+ return sendJson(res, 200, result);
268
+ }
269
+
270
+ if (req.method === 'POST' && pathname === '/api/auth/accept-oferta') {
271
+ const body = await readJsonBody(req);
272
+ const key = extractBearerToken(req, body, url) || body.email || '';
273
+ const user = store.acceptUserOferta(key, body);
190
274
  return sendJson(res, 200, user);
191
275
  }
192
276
 
193
277
  if (req.method === 'GET' && pathname === '/api/auth/me') {
194
- const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
195
- const key = url.searchParams.get('sessionKey') || authHeader;
278
+ const key = extractBearerToken(req, null, url);
196
279
  const user = store.findUserBySessionKey(key);
197
280
  if (!user)
198
281
  return sendJson(res, 401, { authenticated: false });
@@ -238,10 +321,16 @@ export function createLicenseServerApp(options = {}) {
238
321
  return sendJson(res, 200, result);
239
322
  }
240
323
 
241
- // 2.6 Резервирование региональной франшизы с оплатой невозвратного залога 100 000 ₽ через ЮKassa
324
+ // 2.6 Резервирование региональной франшизы с оплатой невозвратного залога 100 000 ₽ через ЮKassa (только после регистрации и подписания оферты)
242
325
  if (req.method === 'POST' && pathname === '/api/partners/reserve') {
243
326
  const body = await readJsonBody(req);
244
- const reserved = store.reserveFranchiseSlot(body);
327
+ const user = ensureCustomerOferta(req, body);
328
+ const reserved = store.reserveFranchiseSlot({
329
+ ...body,
330
+ companyName: body.companyName || user?.companyName || '',
331
+ inn: body.inn || user?.inn || '',
332
+ email: body.email || user?.email || '',
333
+ });
245
334
  const payment = await yookassa.createPaymentForOrder(reserved.order, { originOverride: reqOrigin });
246
335
  return sendJson(res, 200, {
247
336
  ...payment,
@@ -254,9 +343,10 @@ export function createLicenseServerApp(options = {}) {
254
343
  // 2.7 Собственный ИИ-агент лицензионного сервера ODANT-WORK
255
344
  if (req.method === 'POST' && pathname === '/api/agent/chat') {
256
345
  const body = await readJsonBody(req);
257
- const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
258
- const user = store.findUserBySessionKey(body.sessionKey || authHeader);
259
- const reply = await agent.chat({ message: body.message || body.text || '', history: body.history || [], user });
346
+ const tok = extractBearerToken(req, body, url);
347
+ const isAdmin = store.verifyAdminToken(tok);
348
+ const user = store.findUserBySessionKey(body.sessionKey || tok);
349
+ const reply = await agent.chat({ message: body.message || body.text || '', history: body.history || [], user, isAdmin });
260
350
  return sendJson(res, 200, reply);
261
351
  }
262
352
 
@@ -266,16 +356,23 @@ export function createLicenseServerApp(options = {}) {
266
356
  return sendJson(res, 200, calculateOrderQuote(body));
267
357
  }
268
358
 
269
- // 4. Выпуск базовой лицензии ПАК (5 вечных мест + 1 год поддержки)
359
+ // 4. Выпуск базовой лицензии ПАК (10 вечных мест + 1 год поддержки Enterprise)
270
360
  if (req.method === 'POST' && pathname === '/api/license/issue') {
271
361
  const body = await readJsonBody(req);
272
362
  const summary = store.issueOrUpdateLicense(body);
273
363
  return sendJson(res, 200, summary);
274
364
  }
275
365
 
276
- // 5. Синхронизация лицензии с ПАК клиента
366
+ // 5. Синхронизация лицензии с ПАК клиента (или по токену владельца)
277
367
  if (req.method === 'POST' && pathname === '/api/license/sync') {
278
368
  const body = await readJsonBody(req);
369
+ if (body.token) {
370
+ const synced = store.syncByOwnerToken({
371
+ ...body,
372
+ canonicalHubUrl: body.canonicalHubUrl || reqOrigin,
373
+ });
374
+ return sendJson(res, 200, synced);
375
+ }
279
376
  const summary = store.issueOrUpdateLicense({
280
377
  licenseId: body.licenseId,
281
378
  hwid: body.hwid,
@@ -285,6 +382,18 @@ export function createLicenseServerApp(options = {}) {
285
382
  return sendJson(res, 200, summary);
286
383
  }
287
384
 
385
+ // 5.1 Синхронизация и скачивание файлов лицензий (.lic.json) по токену владельца
386
+ if (req.method === 'POST' && pathname === '/api/license/sync-by-token') {
387
+ const body = await readJsonBody(req);
388
+ const tok = body.token || extractBearerToken(req, body, url);
389
+ const result = store.syncByOwnerToken({
390
+ ...body,
391
+ token: tok,
392
+ canonicalHubUrl: body.canonicalHubUrl || reqOrigin,
393
+ });
394
+ return sendJson(res, 200, result);
395
+ }
396
+
288
397
  // 6. Получение лицензии по ID / HWID
289
398
  if (req.method === 'GET' && pathname.startsWith('/api/license/')) {
290
399
  const id = decodeURIComponent(pathname.slice('/api/license/'.length));
@@ -294,10 +403,37 @@ export function createLicenseServerApp(options = {}) {
294
403
  return sendJson(res, 200, summary);
295
404
  }
296
405
 
297
- // 7. Оформление заказа и создание платежа в ЮKassa
406
+ // 7. Оформление заказа: через ЮKassa ИЛИ по договору/счёту для юрлиц (только после регистрации и подписания оферты)
298
407
  if (req.method === 'POST' && pathname === '/api/orders/checkout') {
299
408
  const body = await readJsonBody(req);
300
- const order = store.createOrder(body);
409
+ const user = ensureCustomerOferta(req, body);
410
+ const order = store.createOrder({
411
+ ...body,
412
+ licenseId: body.licenseId || user?.licenseId || '',
413
+ ownerAccountId: user?.userId || body.ownerAccountId || '',
414
+ customer: {
415
+ name: body.customer?.name || user?.companyName || '',
416
+ inn: body.customer?.inn || user?.inn || '',
417
+ kpp: body.customer?.kpp || '',
418
+ legalAddress: body.customer?.legalAddress || '',
419
+ email: body.customer?.email || user?.email || '',
420
+ phone: body.customer?.phone || user?.phone || '',
421
+ },
422
+ });
423
+ if (order.paymentMethod === 'invoice') {
424
+ return sendJson(res, 200, {
425
+ orderId: order.orderId,
426
+ licenseId: order.licenseId,
427
+ paymentMethod: 'invoice',
428
+ status: order.status,
429
+ amountRub: order.amountRub,
430
+ invoiceNumber: order.invoiceNumber,
431
+ contractNumber: order.contractNumber,
432
+ invoiceUrl: `${reqOrigin}${order.invoiceUrl}`,
433
+ confirmationUrl: `${reqOrigin}${order.invoiceUrl}`,
434
+ order,
435
+ });
436
+ }
301
437
  const payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
302
438
  return sendJson(res, 200, {
303
439
  ...payment,
@@ -305,6 +441,15 @@ export function createLicenseServerApp(options = {}) {
305
441
  });
306
442
  }
307
443
 
444
+ // 7.1 Печатная форма Счёта-договора на оплату для юридических лиц
445
+ if (req.method === 'GET' && pathname.startsWith('/api/orders/') && pathname.endsWith('/invoice')) {
446
+ const orderId = decodeURIComponent(pathname.slice('/api/orders/'.length, -'/invoice'.length));
447
+ const html = store.renderOrderInvoiceHtml(orderId);
448
+ if (!html)
449
+ return sendHtml(res, 404, '<h1>Счёт не найден</h1>');
450
+ return sendHtml(res, 200, html);
451
+ }
452
+
308
453
  // 8. Песочница оплаты ЮKassa
309
454
  if (req.method === 'GET' && pathname.startsWith('/pay/sandbox/')) {
310
455
  const orderId = decodeURIComponent(pathname.slice('/pay/sandbox/'.length));
@@ -352,8 +497,35 @@ export function createLicenseServerApp(options = {}) {
352
497
  return sendJson(res, 200, ticket);
353
498
  }
354
499
 
355
- // 12. Обзор для панели управления
500
+ // 12. Авторизация и защищённое API панели администратора (/admin)
501
+ if (req.method === 'POST' && pathname === '/api/admin/login') {
502
+ const body = await readJsonBody(req);
503
+ try {
504
+ const session = store.loginAdmin(body);
505
+ return sendJson(res, 200, session);
506
+ }
507
+ catch (err) {
508
+ return sendJson(res, 401, { authenticated: false, error: err?.message || 'Ошибка авторизации администратора' });
509
+ }
510
+ }
511
+
512
+ if (req.method === 'POST' && pathname === '/api/admin/logout') {
513
+ const body = await readJsonBody(req);
514
+ const tok = extractBearerToken(req, body, url);
515
+ return sendJson(res, 200, store.logoutAdmin(tok));
516
+ }
517
+
518
+ if (req.method === 'GET' && pathname === '/api/admin/me') {
519
+ const tok = extractBearerToken(req, null, url);
520
+ if (!store.verifyAdminToken(tok))
521
+ return sendJson(res, 401, { authenticated: false });
522
+ return sendJson(res, 200, { authenticated: true, login: store.adminLogin });
523
+ }
524
+
356
525
  if (req.method === 'GET' && pathname === '/api/admin/overview') {
526
+ const tok = extractBearerToken(req, null, url);
527
+ if (!store.verifyAdminToken(tok))
528
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
357
529
  return sendJson(res, 200, {
358
530
  licenses: store.listLicenses(),
359
531
  tickets: store.listTickets(),
@@ -361,13 +533,46 @@ export function createLicenseServerApp(options = {}) {
361
533
  partners: store.listPartners(),
362
534
  reservations: Object.values(store.state.reservations || {}),
363
535
  regions: store.listRegionsWithQuotas(),
536
+ users: store.listUsers(),
364
537
  });
365
538
  }
366
539
 
540
+ if (req.method === 'POST' && pathname.startsWith('/api/admin/orders/') && pathname.endsWith('/cancel')) {
541
+ const body = await readJsonBody(req);
542
+ const tok = extractBearerToken(req, body, url);
543
+ if (!store.verifyAdminToken(tok))
544
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
545
+ const orderId = decodeURIComponent(pathname.slice('/api/admin/orders/'.length, -'/cancel'.length));
546
+ return sendJson(res, 200, store.cancelOrder(orderId));
547
+ }
548
+
549
+ if (req.method === 'POST' && pathname.startsWith('/api/admin/orders/') && pathname.endsWith('/confirm-invoice')) {
550
+ const body = await readJsonBody(req);
551
+ const tok = extractBearerToken(req, body, url);
552
+ if (!store.verifyAdminToken(tok))
553
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
554
+ const orderId = decodeURIComponent(pathname.slice('/api/admin/orders/'.length, -'/confirm-invoice'.length));
555
+ const confirmed = store.confirmInvoicePayment(orderId, body);
556
+ if (confirmed?.license) {
557
+ await bots?.notifyLicenseOrderPaid(confirmed.order, confirmed.license);
558
+ }
559
+ return sendJson(res, 200, confirmed);
560
+ }
561
+
562
+ if (req.method === 'POST' && pathname.startsWith('/api/admin/tickets/') && pathname.endsWith('/close')) {
563
+ const body = await readJsonBody(req);
564
+ const tok = extractBearerToken(req, body, url);
565
+ if (!store.verifyAdminToken(tok))
566
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
567
+ const ticketId = decodeURIComponent(pathname.slice('/api/admin/tickets/'.length, -'/close'.length));
568
+ return sendJson(res, 200, store.closeTicket(ticketId));
569
+ }
570
+
367
571
  return sendJson(res, 404, { error: 'Not Found', path: pathname });
368
572
  }
369
573
  catch (err) {
370
- return sendJson(res, 400, { error: err?.message || String(err) });
574
+ const status = Number(err?.statusCode) || 400;
575
+ return sendJson(res, status, { error: err?.message || String(err), ...(err?.code ? { code: err.code } : {}) });
371
576
  }
372
577
  }
373
578