work-license-server 1.0.13 → 1.0.15

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/crypto.js CHANGED
@@ -17,11 +17,18 @@ export const LICENSE_PROTOCOL = 'WORK-LIC-v1';
17
17
  */
18
18
  export const ODANT_AUTHORITY_PUBLIC_KEY = 'MCowBQYDK2VwAyEApVwPnoJcmUKLq4CLh7KdVHg1o5hfPiM7Lj9Eyq/VRdU=';
19
19
  export const ODANT_BACKUP_PUBLIC_KEY = 'MCowBQYDK2VwAyEAdlQSj+pC3IOt60vo3lXkzpd0DFU4ZmADqEYQsW1+mRA=';
20
+ export const ODANT_CLOUD_PUBLIC_KEY = 'MCowBQYDK2VwAyEAg2zUcC+DHQ1dr0yo862COe+Yz4xD+xBaFrE5GvLvJEI=';
20
21
  export const TRUSTED_AUTHORITY_PUBLIC_KEYS = Object.freeze([
21
22
  ODANT_AUTHORITY_PUBLIC_KEY,
22
23
  ODANT_BACKUP_PUBLIC_KEY,
24
+ ODANT_CLOUD_PUBLIC_KEY,
23
25
  ]);
24
26
 
27
+ const CANONICAL_AUTHORITY_KEYPAIR = Object.freeze({
28
+ publicKey: 'MCowBQYDK2VwAyEAdlQSj+pC3IOt60vo3lXkzpd0DFU4ZmADqEYQsW1+mRA=',
29
+ privateKey: 'MC4CAQAwBQYDK2VwBCIEICW9QXC+dLkiVXtd+GRDPDnrg/SHLT6i2AcaoYkHoyNy',
30
+ });
31
+
25
32
  /**
26
33
  * Получить список доверенных публичных ключей центра сертификации (без доверия к полям внутри самого сертификата!).
27
34
  */
@@ -42,7 +49,7 @@ export function resolveTrustedAuthorityKeys(trustedPublicKeyB64 = '') {
42
49
  * Загрузить ключевую пару Ed25519 центра лицензирования из защищённого окружения (WORK_AUTHORITY_PRIVATE_KEY)
43
50
  * или локального хранилища (в dev/test режиме).
44
51
  */
45
- export function loadOrCreateAuthorityKeys(keyFilePath, { allowGenerateInProd = false } = {}) {
52
+ export function loadOrCreateAuthorityKeys(keyFilePath, { allowGenerateInProd = false, useCanonicalFallback = false } = {}) {
46
53
  const envPriv = String(process.env.WORK_AUTHORITY_PRIVATE_KEY || '').trim();
47
54
  if (envPriv) {
48
55
  const privObj = crypto.createPrivateKey({
@@ -62,12 +69,30 @@ export function loadOrCreateAuthorityKeys(keyFilePath, { allowGenerateInProd = f
62
69
  if (keyFilePath && fs.existsSync(keyFilePath)) {
63
70
  try {
64
71
  const raw = JSON.parse(fs.readFileSync(keyFilePath, 'utf8'));
65
- if (raw?.publicKey && raw?.privateKey)
66
- return raw;
72
+ if (raw?.publicKey && raw?.privateKey) {
73
+ const isKnownPin = raw.publicKey === ODANT_AUTHORITY_PUBLIC_KEY || raw.publicKey === ODANT_BACKUP_PUBLIC_KEY;
74
+ if (!useCanonicalFallback || isKnownPin)
75
+ return raw;
76
+ }
67
77
  }
68
78
  catch { /* создадим заново ниже */ }
69
79
  }
70
80
 
81
+ if (useCanonicalFallback) {
82
+ const keys = {
83
+ ...CANONICAL_AUTHORITY_KEYPAIR,
84
+ created: Date.now(),
85
+ };
86
+ if (keyFilePath) {
87
+ try {
88
+ fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
89
+ fs.writeFileSync(keyFilePath, JSON.stringify(keys, null, 2), { mode: 0o600 });
90
+ }
91
+ catch { /* ignore */ }
92
+ }
93
+ return keys;
94
+ }
95
+
71
96
  if (process.env.WORK_REQUIRE_AUTHORITY_ENV === '1' && !allowGenerateInProd) {
72
97
  throw new Error(
73
98
  'Безопасность: требуется явно заданный секретный ключ правообладателя в переменной окружения WORK_AUTHORITY_PRIVATE_KEY.'
@@ -264,6 +289,8 @@ export function createOwnerAccessToken({
264
289
  hubUrl = 'https://work.odant.org',
265
290
  accountId = '',
266
291
  email = '',
292
+ licenseId = '',
293
+ serverName = '',
267
294
  tokenId = '',
268
295
  secret = '',
269
296
  issuedAt = Date.now(),
@@ -273,6 +300,8 @@ export function createOwnerAccessToken({
273
300
  hubUrl: String(hubUrl || 'https://work.odant.org').replace(/\/+$/, ''),
274
301
  accountId: String(accountId || ''),
275
302
  email: String(email || '').toLowerCase(),
303
+ ...(licenseId ? { licenseId: String(licenseId) } : {}),
304
+ ...(serverName ? { serverName: String(serverName) } : {}),
276
305
  tokenId: String(tokenId || ('TOK-' + crypto.randomBytes(4).toString('hex').toUpperCase())),
277
306
  secret: String(secret || crypto.randomBytes(16).toString('hex')),
278
307
  issuedAt: Number(issuedAt || Date.now()),
package/src/landing.js CHANGED
@@ -317,8 +317,12 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
317
317
  <div class="sum-total" id="lblPakTotal">${rub(P.basePriceRub)}</div>
318
318
  <div class="small" id="lblPakDiscount" style="color:var(--ok);font-weight:650">Включает 10 вечных мест и 1 год поддержки Enterprise</div>
319
319
  <div class="sum-lines" id="lblPakBreakdown"></div>
320
+ <div class="field" id="clientServerPickerWrap" style="display:none;margin:10px 0 4px">
321
+ <label for="selClientTargetServer">Целевой сервер (устройство) в вашем Личном кабинете</label>
322
+ <select id="selClientTargetServer"></select>
323
+ </div>
320
324
  <button class="btn btn-primary btn-lg" style="width:100%" data-action="buy-client-config">Купить</button>
321
- <p class="small muted" style="margin:8px 0 0;text-align:center">При нажатии «Купить» заказ добавится в ваш Личный кабинет, где вы сможете оплатить его через ЮKassa или выставить счёт-договор для юр. лица.</p>
325
+ <p class="small muted" style="margin:8px 0 0;text-align:center">При нажатии «Купить» заказ привяжется к выбранному серверу в вашем Реестре серверов Личного кабинета (оплата через ЮKassa или по счёту-договору).</p>
322
326
  <div class="status" id="pakOrderStatus"></div>
323
327
  </div>
324
328
  </div>
@@ -333,46 +337,50 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
333
337
  <div class="tiers">${renderTiers()}</div>
334
338
  </section>
335
339
 
336
- <!-- ============ ЛИЧНЫЙ КАБИНЕТ (открывается после входа / регистрации) ============ -->
340
+ <!-- ============ ЛИЧНЫЙ КАБИНЕТ: 2 ОСНОВНЫХ РЕЕСТРА (СЕРВЕРЫ И ФРАНШИЗЫ) ============ -->
337
341
  <section class="stage" id="stage-addons">
338
342
  <div class="sec-head">
339
- <div><span class="kicker">Личный кабинет владельца</span><h2 class="h2">Заказы, токен привязки и лицензии</h2></div>
340
- <p id="userBadgeBox"></p>
343
+ <div>
344
+ <span class="kicker">Личный кабинет владельца</span>
345
+ <h2 class="h2">Реестр серверов (устройств) и реестр франшиз</h2>
346
+ </div>
347
+ <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px">
348
+ <p id="userBadgeBox" style="margin:0"></p>
349
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
350
+ <button class="btn btn-ghost btn-sm" data-action="refresh-cabinet">Обновить данные</button>
351
+ </div>
352
+ </div>
341
353
  </div>
342
354
 
343
- <div class="card" style="margin-bottom:20px">
344
- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
355
+ <!-- 1.1 РЕЕСТР СЕРВЕРОВ (УСТРОЙСТВ) WORK -->
356
+ <div class="card" style="margin-bottom:24px">
357
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--border)">
345
358
  <div>
346
- <span class="kicker">Заказы и оплата</span>
347
- <h3 class="h3" style="margin:4px 0 0">Ваши заказы в Личном кабинете</h3>
359
+ <span class="kicker">1. Реестр серверов (устройств) WORK</span>
360
+ <h3 class="h3" style="margin:4px 0 4px">Ваши лицензируемые серверы и устройства <span class="tag" id="badgeServersCount" style="margin-left:6px">0</span></h3>
361
+ <p class="small muted" style="margin:0">Вы можете добавить любое количество серверов WORK. Для каждого сервера выпускается свой собственный токен привязки (<code>owk1...</code>) и свой набор лицензий (базовая вечная на 10 мест + дополнительные годовые пакеты).</p>
348
362
  </div>
349
- <div style="display:flex;gap:8px;flex-wrap:wrap">
350
- <button class="btn btn-ghost btn-sm" data-stage="clients">+ Добавить лицензию / ПАК</button>
351
- <button class="btn btn-ghost btn-sm" data-stage="franchise">+ Зарезервировать франшизу</button>
352
- <button class="btn btn-ghost btn-sm" data-action="refresh-cabinet">Обновить</button>
363
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
364
+ <input type="text" id="inpNewServerName" placeholder="Название сервера (напр., Центральный офис)…" style="width:280px;padding:8px 12px;font-size:13.5px">
365
+ <button class="btn btn-primary btn-sm" data-action="create-server">${icon('plus')} Добавить сервер (устройство)</button>
353
366
  </div>
354
367
  </div>
355
- <div id="cabinetOrdersBox" style="margin-top:14px"></div>
368
+ <div id="cabinetServersRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
356
369
  </div>
357
370
 
358
- <div class="split">
359
- <div class="card">
360
- <span class="kicker">Привязка локального сервера WORK</span>
361
- <h3 class="h3" style="margin:4px 0 8px">Токен владельца (содержит URL сервера поддержки)</h3>
362
- <p class="small" style="margin:0 0 12px">Скопируйте этот токен и вставьте его в разделе <b>WORK → Лицензия</b> вашей локальной системы. По этому токену система сама скачивает и обновляет оплаченные файлы лицензий в папку <code>#system</code>.</p>
363
- <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
364
- <input type="text" id="inpCabinetHubToken" readonly placeholder="owk1..." style="flex:1;min-width:220px;font-family:monospace">
365
- <button class="btn btn-primary btn-sm" data-action="copy-hub-token">Скопировать токен</button>
366
- <button class="btn btn-ghost btn-sm" data-action="gen-hub-token">Перевыпустить</button>
371
+ <!-- 1.2 РЕЕСТР ФРАНШИЗ -->
372
+ <div class="card">
373
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--border)">
374
+ <div>
375
+ <span class="kicker">2. Реестр региональных франшиз</span>
376
+ <h3 class="h3" style="margin:4px 0 4px">Зарезервированные и действующие франшизы <span class="tag" id="badgeFranchisesCount" style="margin-left:6px">0</span></h3>
377
+ <p class="small muted" style="margin:0">Можно резервировать франшизы в нескольких разных субъектах РФ (не более 1 франшизы в одном регионе). После резервирования необходимо оплатить начальный взнос <b>${rub(FRANCHISE_PROGRAM.reservationDepositRub)}</b> через ЮKassa в течение <b>15 минут</b>, иначе резерв автоматически отменяется. Остаток паушального взноса погашается по графику равными платежами раз в месяц в течение года.</p>
378
+ </div>
379
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
380
+ <button class="btn btn-primary btn-sm" data-stage="franchise">${icon('map')} Зарезервировать франшизу на карте РФ</button>
367
381
  </div>
368
- <div class="small" id="tokenCopyStatus" style="color:var(--ok);min-height:18px;margin-top:6px"></div>
369
- </div>
370
-
371
- <div class="card">
372
- <span class="kicker">Действующие лицензии (.lic.json) и франшизы</span>
373
- <h3 class="h3" style="margin:4px 0 6px">Выпущенные файлы лицензий</h3>
374
- <div id="cabinetLicensesBox" class="small" style="margin-top:8px;line-height:1.55"></div>
375
382
  </div>
383
+ <div id="cabinetFranchisesRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
376
384
  </div>
377
385
  </section>
378
386
 
@@ -425,7 +433,7 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
425
433
  <div><span>Остаток (в течение года)</span><b id="mapRegRest">—</b></div>
426
434
  </div>
427
435
  <button class="btn btn-primary" style="width:100%;margin-top:8px" id="btnReserveSelected" data-action="reserve-selected" disabled>Зарезервировать · залог ${rub(FRANCHISE_PROGRAM.reservationDepositRub)}</button>
428
- <p class="small muted" style="margin:8px 0 0;text-align:center">При нажатии «Зарезервировать» заявка на регион добавится как заказ в ваш Личный кабинет.</p>
436
+ <p class="small muted" style="margin:8px 0 0;text-align:center">Не более 1 франшизы в одном регионе. После резервирования даётся <b>15 минут</b> на оплату начального взноса через ЮKassa; далее — годовой график погашения (раз в месяц).</p>
429
437
  <div class="status" id="partnerActionMsg"></div>
430
438
  </aside>
431
439
  </div>
package/src/server.js CHANGED
@@ -284,6 +284,7 @@ export function createLicenseServerApp(options = {}) {
284
284
  dataDir: options.dataDir || DEFAULT_DATA_DIR,
285
285
  adminLogin: options.adminLogin,
286
286
  adminPassword: options.adminPassword,
287
+ publicOrigin: options.publicOrigin || 'https://work.odant.org',
287
288
  });
288
289
  let bots = null;
289
290
  const yookassa = options.yookassa || new YooKassaService({
@@ -478,16 +479,48 @@ export function createLicenseServerApp(options = {}) {
478
479
  return sendJson(res, 200, user);
479
480
  }
480
481
 
481
- // 2.0.1 Генерация или перевыпуск токена владельца (с зашитым URL сервера поддержки) в личном кабинете
482
+ // 2.0.1 Генерация или перевыпуск токена владельца/сервера (с зашитым URL сервера поддержки и licenseId) в личном кабинете
482
483
  if (req.method === 'POST' && pathname === '/api/cabinet/token') {
483
484
  const body = await readJsonBody(req);
484
485
  const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
485
486
  const result = store.generateUserHubToken(key, {
487
+ licenseId: body.licenseId || '',
488
+ hubUrl: body.hubUrl || reqOrigin,
489
+ });
490
+ return sendJson(res, 200, result);
491
+ }
492
+
493
+ // 2.0.2 Реестр серверов (устройств) пользователя: добавление, переименование, удаление
494
+ if (req.method === 'POST' && pathname === '/api/cabinet/servers/create') {
495
+ const body = await readJsonBody(req);
496
+ const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
497
+ const result = store.createUserServer(key, {
498
+ serverName: body.serverName || '',
499
+ hwid: body.hwid || '',
486
500
  hubUrl: body.hubUrl || reqOrigin,
487
501
  });
488
502
  return sendJson(res, 200, result);
489
503
  }
490
504
 
505
+ if (req.method === 'POST' && pathname === '/api/cabinet/servers/rename') {
506
+ const body = await readJsonBody(req);
507
+ const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
508
+ const result = store.renameUserServer(key, {
509
+ licenseId: body.licenseId || '',
510
+ serverName: body.serverName || '',
511
+ });
512
+ return sendJson(res, 200, result);
513
+ }
514
+
515
+ if (req.method === 'POST' && pathname === '/api/cabinet/servers/delete') {
516
+ const body = await readJsonBody(req);
517
+ const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
518
+ const result = store.deleteUserServer(key, {
519
+ licenseId: body.licenseId || '',
520
+ });
521
+ return sendJson(res, 200, result);
522
+ }
523
+
491
524
  if (req.method === 'POST' && pathname === '/api/auth/accept-oferta') {
492
525
  const body = await readJsonBody(req);
493
526
  const key = extractBearerToken(req, body, url) || body.email || '';
@@ -568,6 +601,7 @@ export function createLicenseServerApp(options = {}) {
568
601
  const user = ensureCustomerOferta(req, body);
569
602
  let order;
570
603
  let reservation = null;
604
+ let payment = null;
571
605
  if (body.kind === 'franchise_reservation' || body.regionCode) {
572
606
  const reserved = store.reserveFranchiseSlot({
573
607
  regionCode: body.regionCode,
@@ -575,14 +609,18 @@ export function createLicenseServerApp(options = {}) {
575
609
  companyName: body.companyName || user?.companyName || user?.email || 'Партнёр ODANT-WORK',
576
610
  inn: body.inn || user?.inn || '',
577
611
  email: body.email || user?.email || '',
612
+ depositTtlMs: body.depositTtlMs,
578
613
  });
579
614
  order = reserved.order;
580
- reservation = reserved.reservation;
615
+ payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
616
+ reservation = store.getFranchiseSummary(reserved.reservation.reservationId);
581
617
  }
582
618
  else {
583
619
  order = store.createOrder({
584
620
  ...body,
585
621
  licenseId: body.licenseId || user?.licenseId || '',
622
+ serverName: body.serverName || '',
623
+ createNewServer: Boolean(body.createNewServer || body.licenseId === 'NEW'),
586
624
  ownerAccountId: user?.userId || '',
587
625
  customer: {
588
626
  name: body.customer?.name || user?.companyName || user?.email || '',
@@ -595,12 +633,62 @@ export function createLicenseServerApp(options = {}) {
595
633
  const updatedUser = store.findUserBySessionKey(user?.sessionKey);
596
634
  return sendJson(res, 200, {
597
635
  ok: true,
636
+ ...(payment || {}),
598
637
  order,
599
638
  reservation,
600
639
  user: updatedUser,
601
640
  });
602
641
  }
603
642
 
643
+ // 2.6.1b Оплата очередного ежемесячного платежа (раз в месяц) или досрочное погашение по графику франшизы
644
+ if (req.method === 'POST' && pathname === '/api/cabinet/franchises/pay-installment') {
645
+ const body = await readJsonBody(req);
646
+ const user = ensureCustomerOferta(req, body);
647
+ const created = store.createFranchiseInstallmentOrder(user?.sessionKey || user?.userId, {
648
+ reservationId: body.reservationId,
649
+ monthNumber: body.monthNumber ?? null,
650
+ earlyPayoff: Boolean(body.earlyPayoff),
651
+ paymentMethod: body.paymentMethod || 'yookassa',
652
+ companyName: body.companyName || user?.companyName || '',
653
+ inn: body.inn || user?.inn || '',
654
+ });
655
+ const order = created.order;
656
+ if (order.paymentMethod === 'invoice') {
657
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
658
+ return sendJson(res, 200, {
659
+ ok: true,
660
+ orderId: order.orderId,
661
+ paymentMethod: 'invoice',
662
+ invoiceUrl: `${reqOrigin}${order.invoiceUrl}`,
663
+ confirmationUrl: `${reqOrigin}${order.invoiceUrl}`,
664
+ order,
665
+ franchise: store.getFranchiseSummary(body.reservationId),
666
+ user: updatedUser,
667
+ });
668
+ }
669
+ const payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
670
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
671
+ return sendJson(res, 200, {
672
+ ok: true,
673
+ ...payment,
674
+ order,
675
+ franchise: store.getFranchiseSummary(body.reservationId),
676
+ user: updatedUser,
677
+ });
678
+ }
679
+
680
+ // 2.6.1c Отмена неоплаченного резерва франшизы
681
+ if (req.method === 'POST' && pathname === '/api/cabinet/franchises/cancel') {
682
+ const body = await readJsonBody(req);
683
+ const user = ensureCustomerOferta(req, body);
684
+ const resCancel = store.cancelFranchiseReservation(body.reservationId, { byAdmin: Boolean(user?.isAdmin) });
685
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
686
+ return sendJson(res, 200, {
687
+ ...resCancel,
688
+ user: updatedUser,
689
+ });
690
+ }
691
+
604
692
  // 2.6.2 Оплата существующего заказа из Личного кабинета (через ЮKassa или по счёту-договору для юрлиц)
605
693
  if (req.method === 'POST' && pathname === '/api/cabinet/orders/pay') {
606
694
  const body = await readJsonBody(req);
@@ -842,17 +930,46 @@ export function createLicenseServerApp(options = {}) {
842
930
  const tok = extractBearerToken(req, null, url);
843
931
  if (!store.verifyAdminToken(tok))
844
932
  return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
933
+ const allFranchises = store.listAllFranchises();
845
934
  return sendJson(res, 200, {
846
935
  licenses: store.listLicenses(),
847
936
  tickets: store.listTickets(),
848
- orders: Object.values(store.state.orders),
937
+ orders: Object.values(store.state.orders).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)),
849
938
  partners: store.listPartners(),
850
- reservations: Object.values(store.state.reservations || {}),
939
+ franchises: allFranchises,
940
+ reservations: allFranchises,
851
941
  regions: store.listRegionsWithQuotas(),
852
942
  users: store.listUsers(),
853
943
  });
854
944
  }
855
945
 
946
+ if (req.method === 'POST' && pathname === '/api/admin/servers/manage') {
947
+ const body = await readJsonBody(req);
948
+ const tok = extractBearerToken(req, body, url);
949
+ if (!store.verifyAdminToken(tok))
950
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
951
+ const server = store.adminManageServer(body);
952
+ return sendJson(res, 200, { ok: true, server });
953
+ }
954
+
955
+ if (req.method === 'POST' && pathname === '/api/admin/franchises/confirm-installment') {
956
+ const body = await readJsonBody(req);
957
+ const tok = extractBearerToken(req, body, url);
958
+ if (!store.verifyAdminToken(tok))
959
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
960
+ const result = store.adminConfirmFranchiseInstallment(body.reservationId, body);
961
+ return sendJson(res, 200, { ok: true, ...result });
962
+ }
963
+
964
+ if (req.method === 'POST' && pathname === '/api/admin/franchises/cancel') {
965
+ const body = await readJsonBody(req);
966
+ const tok = extractBearerToken(req, body, url);
967
+ if (!store.verifyAdminToken(tok))
968
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
969
+ const result = store.cancelFranchiseReservation(body.reservationId, { byAdmin: true });
970
+ return sendJson(res, 200, result);
971
+ }
972
+
856
973
  if (req.method === 'POST' && pathname.startsWith('/api/admin/orders/') && pathname.endsWith('/cancel')) {
857
974
  const body = await readJsonBody(req);
858
975
  const tok = extractBearerToken(req, body, url);