@alizadefaig/pos-server 1.2.28 → 1.2.31

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.
@@ -170,9 +170,15 @@ const buildAnswer = ({nonce, httpPort, identity}) => {
170
170
  * Проверка ответа мастера (UDP и HTTP). address — откуда ответ пришёл на самом деле: он обязан
171
171
  * быть в подписанных hosts. port — если известен (HTTP): мастер обязан назвать тот же.
172
172
  */
173
- const accept = (message, {key, nonce, address, companyId = null, port = null}) => {
174
- if (!message || message.t !== ANSWER || message.nonce !== nonce) {
175
- return null;
173
+ const check = (message, {key, nonce, address, companyId = null, port = null}) => {
174
+ if (!message) {
175
+ return 'bad_packet';
176
+ }
177
+ if (message.t !== ANSWER) {
178
+ return 'not_answer';
179
+ }
180
+ if (message.nonce !== nonce) {
181
+ return 'stale_nonce';
176
182
  }
177
183
  const hosts = Array.isArray(message.hosts) ? message.hosts.map(String) : [];
178
184
  const fields = {
@@ -183,16 +189,20 @@ const accept = (message, {key, nonce, address, companyId = null, port = null}) =
183
189
  hosts,
184
190
  };
185
191
  if (!Number.isInteger(fields.port) || fields.port <= 0 || fields.port > 65535) {
186
- return null;
192
+ return 'bad_port';
187
193
  }
188
194
  if (port !== null && fields.port !== port) {
189
- return null;
195
+ return 'port_mismatch';
190
196
  }
191
- if (!hosts.includes(address) || !proofMatches(key, fields, message.proof)) {
192
- return null;
197
+ if (!hosts.includes(address)) {
198
+ return 'address_not_in_hosts';
199
+ }
200
+ // Другой ключ зала: мастер и ведомый настроены разными ключами (смена ключа не доехала).
201
+ if (!proofMatches(key, fields, message.proof)) {
202
+ return 'bad_proof';
193
203
  }
194
204
  if (companyId && fields.company_id !== companyId) {
195
- return null;
205
+ return 'other_company';
196
206
  }
197
207
  return {
198
208
  url: `http://${address}:${fields.port}`,
@@ -203,8 +213,31 @@ const accept = (message, {key, nonce, address, companyId = null, port = null}) =
203
213
  };
204
214
  };
205
215
 
216
+ const accept = (message, options) => {
217
+ const result = check(message, options);
218
+ return typeof result === 'string' ? null : result;
219
+ };
220
+
221
+ const bump = (map, key) => {
222
+ map[key] = (map[key] || 0) + 1;
223
+ };
224
+
206
225
  // ---------------------------------------------------------------- мастер: отвечает
207
226
 
227
+ /** Почему buildAnswer вернул null — для диагностики. */
228
+ const noAnswerReason = (identity) => {
229
+ if (!config.lanKey()) {
230
+ return 'no_lan_key';
231
+ }
232
+ if (!identity || !identity.company_id) {
233
+ return 'no_session';
234
+ }
235
+ if (!interfaces().length) {
236
+ return 'no_interfaces';
237
+ }
238
+ return 'bad_nonce';
239
+ };
240
+
208
241
  class Responder {
209
242
  /**
210
243
  * @param {{httpPort: () => number, identity: () => ({company_id, terminal_id}|null)}} options
@@ -217,6 +250,13 @@ class Responder {
217
250
  this.socket = null;
218
251
  this.answered = 0;
219
252
  this.limiter = new Limiter(MAX_ANSWERS_PER_SECOND);
253
+ // Диагностика (/local/status.discovery): доходят ли запросы ведомых до мастера.
254
+ this.received = 0;
255
+ this.rejected = {};
256
+ this.sendErrors = 0;
257
+ this.lastQuery = null;
258
+ this.bound = null;
259
+ this.error = null;
220
260
  }
221
261
 
222
262
  start() {
@@ -228,12 +268,20 @@ class Responder {
228
268
  const socket = dgram.createSocket('udp4');
229
269
  socket.on('error', (e) => {
230
270
  console.warn('[discovery] responder error:', e.message);
271
+ this.error = `${e.code || ''} ${e.message}`.trim();
231
272
  this.stop();
232
273
  });
233
274
  socket.on('message', (buffer, rinfo) => {
275
+ this.received += 1;
276
+ this.lastQuery = {from: rinfo.address, at: new Date().toISOString()};
234
277
  this.answer(socket, parse(buffer), rinfo).catch(() => {});
235
278
  });
236
- socket.bind(port, () => log(`answering on udp/${port}`));
279
+ socket.bind(port, () => {
280
+ const address = socket.address();
281
+ this.bound = `${address.address}:${address.port}`;
282
+ this.error = null;
283
+ log(`answering on udp/${port}`);
284
+ });
237
285
  socket.unref();
238
286
  this.socket = socket;
239
287
  return this;
@@ -241,19 +289,40 @@ class Responder {
241
289
 
242
290
  async answer(socket, message, rinfo) {
243
291
  if (!message || message.t !== QUERY) {
292
+ bump(this.rejected, 'bad_packet');
244
293
  return;
245
294
  }
246
295
  const identity = this.identity();
247
296
  // Ведомый другого заведения в той же сети — не наш.
248
297
  if (identity && message.company_id && message.company_id !== identity.company_id) {
298
+ bump(this.rejected, 'other_company');
249
299
  return;
250
300
  }
251
301
  const answer = buildAnswer({nonce: message.nonce, httpPort: this.httpPort(), identity});
252
- if (!answer || !this.limiter.take()) {
302
+ if (!answer) {
303
+ bump(this.rejected, noAnswerReason(identity));
304
+ return;
305
+ }
306
+ if (!this.limiter.take()) {
307
+ bump(this.rejected, 'rate_limited');
253
308
  return;
254
309
  }
255
310
  this.answered += 1;
256
- await send(socket, answer, rinfo.port, rinfo.address);
311
+ if (!await send(socket, answer, rinfo.port, rinfo.address)) {
312
+ this.sendErrors += 1;
313
+ }
314
+ }
315
+
316
+ status() {
317
+ return {
318
+ bound: this.bound,
319
+ error: this.error,
320
+ received: this.received,
321
+ answered: this.answered,
322
+ rejected: this.rejected,
323
+ send_errors: this.sendErrors,
324
+ last_query: this.lastQuery,
325
+ };
257
326
  }
258
327
 
259
328
  stop() {
@@ -271,21 +340,37 @@ class Responder {
271
340
 
272
341
  const httpLimiter = new Limiter(MAX_ANSWERS_PER_SECOND);
273
342
  let httpAnswered = 0;
343
+ const httpStats = {requests: 0, rejected: {}, last: null};
274
344
 
275
345
  /**
276
346
  * GET /local/whoami?nonce= — тот же подписанный ответ по HTTP (ступени 1 и 3 поиска). Путь
277
347
  * открыт без ключа зала (lan.js): ответ сам доказывает знание ключа и ничего не раскрывает.
278
348
  * null — не отвечаем (не мастер, нет ключа или сессии, превышен лимит).
279
349
  */
280
- const whoami = ({nonce, httpPort, identity}) => {
350
+ const whoami = ({nonce, httpPort, identity, from = null}) => {
351
+ httpStats.requests += 1;
352
+ httpStats.last = {from, at: new Date().toISOString()};
281
353
  const answer = buildAnswer({nonce, httpPort, identity});
282
- if (!answer || !httpLimiter.take()) {
354
+ if (!answer) {
355
+ bump(httpStats.rejected, noAnswerReason(identity));
356
+ return null;
357
+ }
358
+ if (!httpLimiter.take()) {
359
+ bump(httpStats.rejected, 'rate_limited');
283
360
  return null;
284
361
  }
285
362
  httpAnswered += 1;
286
363
  return answer;
287
364
  };
288
365
 
366
+ /** Счётчики /local/whoami мастера: доходят ли HTTP-проверки ведомых. */
367
+ const whoamiStatus = () => ({
368
+ requests: httpStats.requests,
369
+ answered: httpAnswered,
370
+ rejected: {...httpStats.rejected},
371
+ last: httpStats.last,
372
+ });
373
+
289
374
  // ---------------------------------------------------------------- ведомый: ищет
290
375
 
291
376
  /**
@@ -295,9 +380,12 @@ const whoami = ({nonce, httpPort, identity}) => {
295
380
  * @param {{companyId?: string, targets?: string[]}} options
296
381
  * targets — куда слать вместо широковещательных адресов (тесты, сети без broadcast).
297
382
  */
298
- const probe = ({companyId = null, targets = null} = {}) => new Promise((resolve) => {
383
+ const probe = ({companyId = null, targets = null, stats = null} = {}) => new Promise((resolve) => {
299
384
  const key = config.lanKey();
300
385
  if (!key) {
386
+ if (stats) {
387
+ stats.error = 'no_lan_key';
388
+ }
301
389
  resolve([]);
302
390
  return;
303
391
  }
@@ -321,12 +409,24 @@ const probe = ({companyId = null, targets = null} = {}) => new Promise((resolve)
321
409
  };
322
410
  const timer = setTimeout(finish, window);
323
411
 
324
- socket.on('error', finish);
412
+ socket.on('error', (e) => {
413
+ if (stats) {
414
+ stats.error = `${e.code || ''} ${e.message}`.trim();
415
+ }
416
+ finish();
417
+ });
325
418
  socket.on('message', (buffer, rinfo) => {
326
- const candidate = accept(parse(buffer), {key, nonce, address: rinfo.address, companyId});
327
- if (candidate) {
328
- found.set(rinfo.address, candidate);
419
+ const result = check(parse(buffer), {key, nonce, address: rinfo.address, companyId});
420
+ if (stats) {
421
+ stats.answers += 1;
422
+ }
423
+ if (typeof result === 'string') {
424
+ if (stats && Object.keys(stats.rejected).length < 20) {
425
+ bump(stats.rejected, `${result}@${rinfo.address}`);
426
+ }
427
+ return;
329
428
  }
429
+ found.set(rinfo.address, result);
330
430
  });
331
431
  socket.bind(0, async () => {
332
432
  socket.setBroadcast(true);
@@ -334,7 +434,12 @@ const probe = ({companyId = null, targets = null} = {}) => new Promise((resolve)
334
434
  const addresses = targets && targets.length
335
435
  ? targets
336
436
  : [...new Set([...interfaces().map((item) => item.broadcast), '255.255.255.255'])];
337
- await Promise.all(addresses.map((address) => send(socket, message, port, address)));
437
+ const results = await Promise.all(addresses.map((address) => send(socket, message, port, address)));
438
+ if (stats) {
439
+ stats.targets = addresses;
440
+ stats.sent = results.filter(Boolean).length;
441
+ stats.send_errors = results.length - stats.sent;
442
+ }
338
443
  });
339
444
  });
340
445
 
@@ -342,16 +447,34 @@ const probe = ({companyId = null, targets = null} = {}) => new Promise((resolve)
342
447
  * Проверка адреса по HTTP: GET <url>/local/whoami со свежим nonce. Отдаёт кандидата или null.
343
448
  * Только http и IPv4 — подпись мастера перечисляет его IPv4, по имени хоста её не сверить.
344
449
  */
345
- const verify = async (url, {companyId = null, timeout = VERIFY_TIMEOUT_MS} = {}) => {
450
+ const verify = async (url, options = {}) => {
451
+ const result = await verifyWhy(url, options);
452
+ return typeof result === 'string' ? null : result;
453
+ };
454
+
455
+ /** Сетевая ошибка fetch → короткий код: timeout, ECONNREFUSED, EHOSTUNREACH, … */
456
+ const fetchError = (e) => {
457
+ if (e && e.name === 'AbortError') {
458
+ return 'timeout';
459
+ }
460
+ const cause = e && e.cause;
461
+ return (cause && (cause.code || cause.name)) || (e && (e.code || e.name)) || 'error';
462
+ };
463
+
464
+ /** Как verify, но вместо null — причина отказа строкой (для диагностики). */
465
+ const verifyWhy = async (url, {companyId = null, timeout = VERIFY_TIMEOUT_MS} = {}) => {
346
466
  const key = config.lanKey();
347
467
  let target;
348
468
  try {
349
469
  target = new URL(url);
350
470
  } catch (e) {
351
- return null;
471
+ return 'bad_url';
352
472
  }
353
- if (!key || target.protocol !== 'http:' || !IPV4.test(target.hostname)) {
354
- return null;
473
+ if (!key) {
474
+ return 'no_lan_key';
475
+ }
476
+ if (target.protocol !== 'http:' || !IPV4.test(target.hostname)) {
477
+ return 'bad_url';
355
478
  }
356
479
  const port = Number(target.port || 80);
357
480
  const nonce = randomBytes(16).toString('hex');
@@ -363,12 +486,12 @@ const verify = async (url, {companyId = null, timeout = VERIFY_TIMEOUT_MS} = {})
363
486
  redirect: 'error',
364
487
  });
365
488
  if (!response.ok) {
366
- return null;
489
+ return `http_${response.status}`;
367
490
  }
368
491
  const body = Buffer.from(await response.arrayBuffer());
369
- return accept(parse(body), {key, nonce, address: target.hostname, companyId, port});
492
+ return check(parse(body), {key, nonce, address: target.hostname, companyId, port});
370
493
  } catch (e) {
371
- return null;
494
+ return fetchError(e);
372
495
  } finally {
373
496
  clearTimeout(timer);
374
497
  }
@@ -378,12 +501,30 @@ const verify = async (url, {companyId = null, timeout = VERIFY_TIMEOUT_MS} = {})
378
501
  * Скан подсети по HTTP пачками по SCAN_BATCH. Останавливается на первой пачке, где кто-то
379
502
  * ответил. hosts — вместо своей подсети (тесты).
380
503
  */
381
- const scan = async ({companyId = null, hosts = null, port = settings().httpPort} = {}) => {
504
+ const scan = async ({companyId = null, hosts = null, port = settings().httpPort, stats = null} = {}) => {
382
505
  const list = hosts || subnetHosts();
506
+ if (stats) {
507
+ stats.hosts = list.length;
508
+ stats.first = list[0] || null;
509
+ stats.last = list[list.length - 1] || null;
510
+ }
383
511
  for (let i = 0; i < list.length; i += SCAN_BATCH) {
384
- const answers = await Promise.all(list.slice(i, i + SCAN_BATCH)
385
- .map((host) => verify(`http://${host}:${port}`, {companyId, timeout: SCAN_TIMEOUT_MS})));
386
- const found = answers.filter(Boolean);
512
+ const batch = list.slice(i, i + SCAN_BATCH);
513
+ const answers = await Promise.all(batch
514
+ .map((host) => verifyWhy(`http://${host}:${port}`, {companyId, timeout: SCAN_TIMEOUT_MS})));
515
+ if (stats) {
516
+ answers.forEach((answer, index) => {
517
+ if (typeof answer !== 'string') {
518
+ return;
519
+ }
520
+ bump(stats.errors, answer);
521
+ // Кто-то ответил по HTTP, но не как наш мастер — адрес интересен: возможно, это он.
522
+ if (answer !== 'timeout' && answer !== 'ECONNREFUSED' && stats.responded.length < 10) {
523
+ stats.responded.push(`${batch[index]}:${answer}`);
524
+ }
525
+ });
526
+ }
527
+ const found = answers.filter((answer) => typeof answer !== 'string');
387
528
  if (found.length) {
388
529
  return found;
389
530
  }
@@ -420,6 +561,9 @@ class Finder {
420
561
  this.lastFoundAt = null;
421
562
  this.candidates = [];
422
563
  this.conflict = false;
564
+ this.searches = 0;
565
+ this.lastSearch = null;
566
+ this.lastScan = null;
423
567
  }
424
568
 
425
569
  start() {
@@ -468,30 +612,44 @@ class Finder {
468
612
 
469
613
  async search() {
470
614
  this.lastProbeAt = new Date().toISOString();
615
+ this.searches += 1;
471
616
  const companyId = this.companyId();
472
617
  let candidates = [];
473
618
  let via = null;
619
+ // Диагностика: что дала каждая ступень последнего поиска.
620
+ const report = {at: this.lastProbeAt, company_id: companyId, known: {}, udp: null};
474
621
  // 1. известный адрес: текущий или сохранённый с прошлого запуска
475
622
  const known = [...new Set([this.current, this.remembered()].filter(Boolean))];
476
623
  for (const url of known) {
477
- const candidate = await verify(url, {companyId});
478
- if (candidate) {
479
- candidates = [candidate];
480
- via = 'saved';
481
- break;
624
+ const candidate = await verifyWhy(url, {companyId});
625
+ if (typeof candidate === 'string') {
626
+ report.known[url] = candidate;
627
+ continue;
482
628
  }
629
+ report.known[url] = 'ok';
630
+ candidates = [candidate];
631
+ via = 'saved';
632
+ break;
483
633
  }
484
634
  // 2. широковещательный запрос
485
635
  if (!candidates.length && !this.stopped) {
486
- candidates = await probe({companyId, targets: this.targets});
636
+ const stats = {targets: [], sent: 0, send_errors: 0, answers: 0, rejected: {}, error: null};
637
+ candidates = await probe({companyId, targets: this.targets, stats});
638
+ report.udp = stats;
487
639
  via = 'udp';
488
640
  }
489
641
  // 3. скан подсети — широковещание могло не дойти (фильтр точки доступа, брандмауэр)
490
642
  if (!candidates.length && !this.stopped && this.scanDue()) {
491
643
  this.lastScanAt = Date.now();
492
- candidates = await scan({companyId, hosts: this.scanHosts});
644
+ const stats = {at: new Date().toISOString(), hosts: 0, first: null, last: null,
645
+ errors: {}, responded: [], found: 0};
646
+ candidates = await scan({companyId, hosts: this.scanHosts, stats});
647
+ stats.found = candidates.length;
648
+ this.lastScan = stats;
493
649
  via = 'scan';
494
650
  }
651
+ report.found = candidates.map((c) => c.url);
652
+ this.lastSearch = report;
495
653
  if (this.stopped) {
496
654
  return;
497
655
  }
@@ -532,6 +690,10 @@ class Finder {
532
690
  conflict: this.conflict,
533
691
  via: this.via,
534
692
  last_scan_at: this.lastScanAt ? new Date(this.lastScanAt).toISOString() : null,
693
+ searches: this.searches,
694
+ interfaces: interfaces(),
695
+ last_search: this.lastSearch,
696
+ last_scan: this.lastScan,
535
697
  };
536
698
  }
537
699
 
@@ -543,4 +705,5 @@ class Finder {
543
705
 
544
706
  const httpAnswers = () => httpAnswered;
545
707
 
546
- module.exports = {Responder, Finder, probe, verify, scan, whoami, httpAnswers, sign, interfaces, settings};
708
+ module.exports = {Responder, Finder, probe, verify, verifyWhy, scan, whoami, whoamiStatus, httpAnswers, sign,
709
+ interfaces, settings};
package/app/sync/index.js CHANGED
@@ -7,6 +7,7 @@ const {CloudSignal} = require('./signal');
7
7
  const {MasterBridge} = require('./bridge');
8
8
  const {lanHeaders} = require('./lan');
9
9
  const discovery = require('./discovery');
10
+ const netstats = require('../netstats');
10
11
  const lanDevices = require('./lanDevices');
11
12
  const lanFront = require('./lanFront');
12
13
  const {getConfig} = require('../config');
@@ -100,9 +101,7 @@ const state = {
100
101
  // чеки, которые облако просит перечитать (sync:pull)
101
102
  pullIds: new Set(),
102
103
 
103
- // выход терминала (logout): с какого момента облако на связи, а очередь ещё не пуста;
104
- // идёт отзыв токена — возврат на кассу (resume) в это время невозможен
105
- revokeWaitFrom: null,
104
+ // выход терминала (logout): идёт отзыв токена — возврат на кассу (resume) в это время невозможен
106
105
  revoking: false,
107
106
 
108
107
  // сверка касса ↔ облако (reconcile.js): последний итог, расхождений подряд, сколько вылечено
@@ -386,10 +385,12 @@ const authoritativeEpoch = () => (
386
385
  : null
387
386
  );
388
387
 
389
- const runPush = async () => {
388
+ // until — выход терминала: досылать, пока облако отвечает (без гистерезиса), не дольше этого срока.
389
+ const runPush = async ({until = null} = {}) => {
390
390
  let rounds = 0;
391
391
  reconcilePoison();
392
- while (refreshPending() > 0 && rounds < MAX_PUSH_ROUNDS && state.online) {
392
+ const going = () => (until === null ? state.online : state.oks > 0 && Date.now() < until);
393
+ while (refreshPending() > 0 && rounds < MAX_PUSH_ROUNDS && going()) {
393
394
  rounds += 1;
394
395
  const epoch = authoritativeEpoch();
395
396
  let result;
@@ -1125,8 +1126,8 @@ const startDiscovery = () => {
1125
1126
  };
1126
1127
 
1127
1128
  /** Ответ мастера на GET /local/whoami (ступени поиска ведомого). null — 404. */
1128
- const whoami = (nonce) => (config.role() === 'master' && state.started
1129
- ? discovery.whoami({nonce, httpPort: getConfig().port, identity: state.session})
1129
+ const whoami = (nonce, from = null) => (config.role() === 'master' && state.started
1130
+ ? discovery.whoami({nonce, httpPort: getConfig().port, identity: state.session, from})
1130
1131
  : null);
1131
1132
 
1132
1133
  /**
@@ -1244,7 +1245,6 @@ const setSession = async (input) => {
1244
1245
  // Фронт снова прислал сессию — значит, терминал вошёл обратно (resume).
1245
1246
  delete state.session.parked;
1246
1247
  }
1247
- state.revokeWaitFrom = null;
1248
1248
  return {success: true, mode: state.mode};
1249
1249
  };
1250
1250
 
@@ -1260,7 +1260,6 @@ const clearSession = async () => {
1260
1260
  }
1261
1261
  stopComponents();
1262
1262
  state.session = null;
1263
- state.revokeWaitFrom = null;
1264
1263
  await state.store.block((s) => kv.del(s, 'session'));
1265
1264
  updateMode();
1266
1265
  return {success: true};
@@ -1273,12 +1272,12 @@ const clearSession = async () => {
1273
1272
  * (auth.logoutApp) и стирал сессию синка: без интернета выход падал, а недосланное
1274
1273
  * ждало следующего входа, которого без интернета не бывает (app.auth — только облако).
1275
1274
  *
1276
- * Теперь выход «паркует» сессию: фронт свою авторизацию стирает, а синк токен держит, пока
1277
- * он нужен, — досылает очередь, а мастер модели M продолжает обслуживать зал. Токен
1278
- * отзывается, когда облако на связи, очередь пуста и касса не держит заведение
1279
- * (очередь не опустела за OFFLINE_LOGOUT_GRACE_MS — отзыв всё равно, недосланное уйдёт со
1280
- * следующим входом в это заведение). До отзыва на кассу можно вернуться по PIN сотрудника
1281
- * (resume) — это единственный вход без облака.
1275
+ * Теперь выход «паркует» сессию: фронт свою авторизацию стирает, а синк держит токен до
1276
+ * первого ответа облака. Облако ответило (гистерезис связи не ждём) — синк досылает очередь не
1277
+ * дольше OFFLINE_LOGOUT_DRAIN_MS и отзывает токен в любом случае, в том числе на мастере модели M
1278
+ * (заведение он после этого не обслуживает). Недосланное остаётся помеченным и уйдёт со
1279
+ * следующим входом в это заведение на этой кассе. До отзыва на кассу можно вернуться по PIN
1280
+ * сотрудника (resume) — это единственный вход без облака.
1282
1281
  */
1283
1282
 
1284
1283
  const initNames = () => {
@@ -1324,8 +1323,11 @@ const revokeToken = async (client, data = {}, timeout = config.timeouts().api) =
1324
1323
  }
1325
1324
  };
1326
1325
 
1326
+ // Облако ответило на последнюю проверку связи: для выхода гистерезис не ждём.
1327
+ const cloudAnswers = () => state.oks > 0;
1328
+
1327
1329
  const canRevokeParked = () => Boolean(state.session && state.session.parked && state.cloud)
1328
- && state.online === true && !state.revoking && !isServingAsMaster();
1330
+ && cloudAnswers() && !state.revoking;
1329
1331
 
1330
1332
  /** Отзыв токена запаркованной сессии. Вернулись на кассу, пока шёл запрос, — сессию не трогаем. */
1331
1333
  const revokeParked = async (timeout) => {
@@ -1347,16 +1349,20 @@ const revokeParked = async (timeout) => {
1347
1349
 
1348
1350
  const runRevoke = async () => {
1349
1351
  if (!canRevokeParked()) {
1350
- state.revokeWaitFrom = null;
1351
1352
  return;
1352
1353
  }
1353
- if (refreshPending() > 0) {
1354
- state.revokeWaitFrom = state.revokeWaitFrom || Date.now();
1355
- if (Date.now() - state.revokeWaitFrom < config.intervals().logoutGrace) {
1354
+ if (!isFollower() && hasInit() && refreshPending() > 0) {
1355
+ await step('logout push', () => runPush({until: Date.now() + config.intervals().logoutDrain}));
1356
+ if (!canRevokeParked()) {
1356
1357
  return;
1357
1358
  }
1358
- console.warn(`[sync] logout: ${state.pending} unsent record(s) still queued after the grace period — `
1359
- + 'revoking the token anyway, they wait for the next login to this company');
1359
+ if (refreshPending() > 0) {
1360
+ console.warn(`[sync] logout: ${state.pending} unsent record(s) left — revoking the token anyway, `
1361
+ + 'they wait for the next login to this company');
1362
+ }
1363
+ }
1364
+ if (isServingAsMaster()) {
1365
+ console.warn('[sync] terminal logged out on the master — the company is not served after the token is revoked');
1360
1366
  }
1361
1367
  await revokeParked();
1362
1368
  };
@@ -1376,7 +1382,7 @@ const runLogoutQueue = async () => {
1376
1382
  };
1377
1383
 
1378
1384
  const runLogout = async () => {
1379
- if (state.online === true && state.store && (kv.get(state.store, 'logout_queue') || []).length) {
1385
+ if (cloudAnswers() && state.store && (kv.get(state.store, 'logout_queue') || []).length) {
1380
1386
  await step('logout queue', runLogoutQueue);
1381
1387
  }
1382
1388
  await step('revoke', runRevoke);
@@ -1402,9 +1408,7 @@ const logout = async ({token} = {}) => {
1402
1408
  if (canRevokeParked() && refreshPending() === 0 && await revokeParked(REVOKE_TIMEOUT_MS)) {
1403
1409
  return {success: true, handled: true, revoked: true};
1404
1410
  }
1405
- if (isServingAsMaster()) {
1406
- console.warn('[sync] terminal logged out on the master — the company is still served, token kept');
1407
- }
1411
+ // Очередь не пуста или облако не ответило — досылка и отзыв идут в цикле сразу же.
1408
1412
  schedule(0);
1409
1413
  return {success: true, handled: true, parked: true, pending: state.pending, master: isServingAsMaster()};
1410
1414
  };
@@ -1438,7 +1442,7 @@ const resume = async ({code} = {}) => {
1438
1442
  const key = throttleKey(session.team_id, session.terminal_id);
1439
1443
  unlockThrottle.assertAllowed(key);
1440
1444
  const user = code
1441
- ? await findUnlockUser(state.store, Array.isArray(blob.users) ? blob.users : [], code, blob.pin_salt)
1445
+ ? await findUnlockUser(state.store, Array.isArray(blob.users) ? blob.users : [], code, blob.pin_salt, blob.terminal)
1442
1446
  : null;
1443
1447
  if (!user) {
1444
1448
  unlockThrottle.fail(key);
@@ -1450,7 +1454,6 @@ const resume = async ({code} = {}) => {
1450
1454
  throw busy();
1451
1455
  }
1452
1456
  delete session.parked;
1453
- state.revokeWaitFrom = null;
1454
1457
  await state.store.block((s) => kv.set(s, 'session', {...session}));
1455
1458
  log('terminal logged back in by PIN');
1456
1459
  return {
@@ -1523,6 +1526,10 @@ const status = () => {
1523
1526
 
1524
1527
  const result = {
1525
1528
  enabled,
1529
+ // Фронт деплоится раньше, чем pos-server доезжает до касс: по версии и списку событий
1530
+ // ядра он понимает, что этот Node умеет офлайн (лениво: ядро само требует app/sync).
1531
+ version: PACKAGE_VERSION,
1532
+ core_events: require('../core').offlineEvents(),
1526
1533
  mode: enabled ? state.mode : 'cloud',
1527
1534
  role: config.role(),
1528
1535
  session: Boolean(state.session),
@@ -1574,12 +1581,16 @@ const status = () => {
1574
1581
  ready: Boolean(state.signal && state.signal.relayReady),
1575
1582
  };
1576
1583
  result.discovery = state.responder
1577
- ? {port: discovery.settings().port, answering: Boolean(state.responder.socket), answered: state.responder.answered}
1584
+ ? {port: discovery.settings().port, answering: Boolean(state.responder.socket),
1585
+ answered: state.responder.answered, udp: state.responder.status(),
1586
+ whoami: discovery.whoamiStatus(), interfaces: discovery.interfaces()}
1578
1587
  : null;
1579
1588
  result.lan = config.isLanFrontEnabled()
1580
1589
  ? {urls: lanFront.urls(getConfig().port), front: lanFront.status(), devices: lanDevices.status()}
1581
1590
  : null;
1582
1591
  }
1592
+ // Слушает ли Node на 0.0.0.0 и доходят ли до него соединения из зала (app/netstats.js).
1593
+ result.net = netstats.snapshot();
1583
1594
  return result;
1584
1595
  };
1585
1596
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alizadefaig/pos-server",
3
- "version": "1.2.28",
3
+ "version": "1.2.31",
4
4
  "description": "Server in express js with Proxy to Main server if need and will work with Electron JS",
5
5
  "homepage": "https://gitlab.com/faigalizade/pos-server#readme",
6
6
  "bugs": {
@@ -99,7 +99,7 @@ registerOfflineRoutes(router, '/api', {
99
99
  const key = throttleKey(context.teamId, context.terminalId);
100
100
  unlockThrottle.assertAllowed(key);
101
101
  const blob = readInit();
102
- const user = await findUnlockUser(db, Array.isArray(blob.users) ? blob.users : [], code, blob.pin_salt);
102
+ const user = await findUnlockUser(db, Array.isArray(blob.users) ? blob.users : [], code, blob.pin_salt, blob.terminal);
103
103
  if (!user) {
104
104
  unlockThrottle.fail(key);
105
105
  return null;