Mbonea commited on
Commit
6fd861b
·
1 Parent(s): d236d70

Add portal auth timing logs and payment error handling

Browse files
public/engine.js CHANGED
@@ -185,6 +185,13 @@
185
 
186
  // ── Auth with code ────────────────────────────────────────────────────────
187
  async function authorizeWithCode(code) {
 
 
 
 
 
 
 
188
  try {
189
  const res = await fetch(`/portal/${P.siteId}/auth`, {
190
  method: 'POST',
@@ -264,6 +271,9 @@
264
  if (!code || code.length < 9) {
265
  return showError('code-error', 'Enter a valid 8-character code (XXXX-XXXX).');
266
  }
 
 
 
267
 
268
  codeSubmitBtn.disabled = true;
269
  codeSubmitBtn.textContent = 'Connecting...';
 
185
 
186
  // ── Auth with code ────────────────────────────────────────────────────────
187
  async function authorizeWithCode(code) {
188
+ if (!P.clientMac) {
189
+ purchaseInFlight = false;
190
+ showScreen('code');
191
+ showError('code-error', 'This page was opened outside the WiFi login flow. Reopen the hotspot portal on the device that is joining the WiFi.');
192
+ return;
193
+ }
194
+
195
  try {
196
  const res = await fetch(`/portal/${P.siteId}/auth`, {
197
  method: 'POST',
 
271
  if (!code || code.length < 9) {
272
  return showError('code-error', 'Enter a valid 8-character code (XXXX-XXXX).');
273
  }
274
+ if (!P.clientMac) {
275
+ return showError('code-error', 'Open this portal from the WiFi login page on the device you want to connect.');
276
+ }
277
 
278
  codeSubmitBtn.disabled = true;
279
  codeSubmitBtn.textContent = 'Connecting...';
src/routes/devices.routes.js CHANGED
@@ -785,7 +785,18 @@ router.post("/:id/renew", async (req, res) => {
785
  metadata: { reference, type: "device_renewal", device_id: String(device.id) },
786
  });
787
  } catch (payErr) {
788
- console.error("[devices/renew] Snippe error:", payErr.message, payErr.response?.data);
 
 
 
 
 
 
 
 
 
 
 
789
  }
790
 
791
  res.json({
 
785
  metadata: { reference, type: "device_renewal", device_id: String(device.id) },
786
  });
787
  } catch (payErr) {
788
+ const paymentError = snippe.extractPaymentError(payErr, "Could not start renewal payment");
789
+ console.error("[devices/renew] Snippe error:", paymentError.message, paymentError.raw);
790
+ await db.query(
791
+ `UPDATE device_payments
792
+ SET status = 'failed'
793
+ WHERE reference = ? AND status = 'pending'`,
794
+ [reference],
795
+ );
796
+ return res.status(paymentError.status).json({
797
+ error: paymentError.message,
798
+ code: paymentError.code,
799
+ });
800
  }
801
 
802
  res.json({
src/routes/portal.routes.js CHANGED
@@ -35,6 +35,38 @@ function wait(ms) {
35
  return new Promise(resolve => setTimeout(resolve, ms));
36
  }
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  function tokenRateLimitOptions(token) {
39
  return {
40
  downLimit: token.down_limit,
@@ -314,8 +346,13 @@ router.post('/:siteId/purchase', purchaseLimiter, async (req, res) => {
314
  },
315
  });
316
  } catch (payErr) {
317
- console.error('[portal/purchase] Snippe error:', payErr.message, payErr.response?.data);
318
- // Don't fail — webhook will confirm; guest can poll
 
 
 
 
 
319
  }
320
 
321
  res.json({ reference, status: 'pending' });
@@ -368,46 +405,72 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
368
  }
369
 
370
  const mac = clientMac.toUpperCase().replace(/:/g, '-');
 
 
371
 
372
  try {
 
 
373
  const token = await db.query(
374
  `SELECT t.*, d.omada_site_id
375
  FROM access_tokens t
376
  JOIN devices d ON d.id = t.device_id
377
  WHERE t.code = ? AND d.omada_site_id = ? LIMIT 1`,
378
- [code.toUpperCase(), siteId]
379
  ).then(r => r[0]);
380
-
381
- if (!token) return res.status(404).json({ error: 'Invalid code' });
 
 
 
 
 
 
 
 
 
382
 
383
  if (token.status === 'expired' || token.status === 'revoked') {
 
384
  return res.status(410).json({ error: 'Code has expired' });
385
  }
386
 
387
  if (token.status === 'active') {
388
- // Reconnect — verify same MAC
 
389
  if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
 
390
  return res.status(403).json({ error: 'Code is locked to another device' });
391
  }
392
- // Check not expired
393
  if (token.expires_at && new Date(token.expires_at) < new Date()) {
394
  await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
 
395
  return res.status(410).json({ error: 'Code has expired' });
396
  }
 
 
397
  }
398
 
399
  if (token.status !== 'unused' && token.status !== 'active') {
 
400
  return res.status(400).json({ error: 'Code cannot be used' });
401
  }
 
402
 
403
- // Authorize on Omada. Rate-limit updates are applied after success so they do
404
- // not keep guests waiting on a slower client PATCH call.
405
  try {
 
406
  await omada.authorizeClient(siteId, mac);
 
407
  } catch (omadaErr) {
408
- console.error('[portal/auth] Omada auth failed:', omadaErr.message);
 
 
 
 
 
409
  alertAdmin(
410
- `portal-auth:${siteId}:${mac}:${code.toUpperCase()}:omada`,
411
  formatPortalAuthAlert({
412
  deviceName: token.name || token.device_name,
413
  siteId,
@@ -420,10 +483,11 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
420
  }
421
 
422
  applyRateLimitLater(siteId, mac, token);
 
423
 
424
- // Activate unused token once; active tokens keep their existing expiry window.
425
  const now = new Date();
426
  let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
 
427
 
428
  if (token.status === 'unused') {
429
  expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
@@ -437,19 +501,21 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
437
  WHERE id = ?`,
438
  [mac, now, expiresAt, token.id]
439
  );
 
440
  } else if (!expiresAt) {
441
  expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
442
  await db.query(
443
  `UPDATE access_tokens SET expires_at = ? WHERE id = ?`,
444
  [expiresAt, token.id]
445
  );
 
446
  }
447
 
448
- // Start or refresh an active session immediately when the device is online.
449
  const activeSession = await db.query(
450
  'SELECT id FROM sessions WHERE access_token_id = ? AND client_mac = ? AND is_active = 1 LIMIT 1',
451
  [token.id, mac]
452
  ).then(r => r[0]);
 
453
 
454
  if (activeSession) {
455
  await db.query(
@@ -458,16 +524,19 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
458
  WHERE id = ?`,
459
  [activeSession.id]
460
  );
 
461
  } else {
462
  await db.query(
463
  `INSERT INTO sessions (access_token_id, client_id, device_id, client_mac, started_at, last_seen_at, is_active)
464
  VALUES (?, ?, ?, ?, NOW(), NOW(), 1)`,
465
  [token.id, token.client_id, token.device_id, mac]
466
  );
 
467
  }
468
 
469
  const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
470
  const secsLeft = Math.floor((expiresAt - now) / 1000);
 
471
 
472
  res.json({
473
  success: true,
@@ -477,15 +546,21 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
477
  speedDown: unitLabel(token.down_limit, token.down_unit),
478
  speedUp: unitLabel(token.up_limit, token.up_unit),
479
  });
 
480
  } catch (err) {
481
- console.error('[portal/auth]', err.message);
 
 
 
 
 
482
  alertAdmin(
483
- `portal-auth:${siteId}:${mac}:${code.toUpperCase()}:fatal`,
484
  formatPortalAuthAlert({
485
  deviceName: null,
486
  siteId,
487
  mac,
488
- code: String(code || '').toUpperCase(),
489
  error: err.message || 'Authorization failed',
490
  })
491
  ).catch(() => {});
 
35
  return new Promise(resolve => setTimeout(resolve, ms));
36
  }
37
 
38
+ async function failPortalPayment(reference) {
39
+ await db.query(
40
+ `UPDATE payments
41
+ SET status = 'failed'
42
+ WHERE reference = ? AND status = 'pending'`,
43
+ [reference]
44
+ );
45
+ }
46
+
47
+ function createAuthTrace({ siteId, mac, code }) {
48
+ const startedAt = Date.now();
49
+ let lastAt = startedAt;
50
+
51
+ return {
52
+ step(label, extra = null) {
53
+ const now = Date.now();
54
+ const stepMs = now - lastAt;
55
+ const totalMs = now - startedAt;
56
+ lastAt = now;
57
+
58
+ if (extra !== null) {
59
+ console.log(`[portal/auth] step=${label} stepMs=${stepMs} totalMs=${totalMs} site=${siteId} mac=${mac} code=${code}`, extra);
60
+ } else {
61
+ console.log(`[portal/auth] step=${label} stepMs=${stepMs} totalMs=${totalMs} site=${siteId} mac=${mac} code=${code}`);
62
+ }
63
+ },
64
+ total() {
65
+ return Date.now() - startedAt;
66
+ },
67
+ };
68
+ }
69
+
70
  function tokenRateLimitOptions(token) {
71
  return {
72
  downLimit: token.down_limit,
 
346
  },
347
  });
348
  } catch (payErr) {
349
+ const paymentError = snippe.extractPaymentError(payErr, 'Could not start payment');
350
+ console.error('[portal/purchase] Snippe error:', paymentError.message, paymentError.raw);
351
+ await failPortalPayment(reference);
352
+ return res.status(paymentError.status).json({
353
+ error: paymentError.message,
354
+ code: paymentError.code,
355
+ });
356
  }
357
 
358
  res.json({ reference, status: 'pending' });
 
405
  }
406
 
407
  const mac = clientMac.toUpperCase().replace(/:/g, '-');
408
+ const normalizedCode = code.toUpperCase();
409
+ const trace = createAuthTrace({ siteId, mac, code: normalizedCode });
410
 
411
  try {
412
+ trace.step('request_received');
413
+
414
  const token = await db.query(
415
  `SELECT t.*, d.omada_site_id
416
  FROM access_tokens t
417
  JOIN devices d ON d.id = t.device_id
418
  WHERE t.code = ? AND d.omada_site_id = ? LIMIT 1`,
419
+ [normalizedCode, siteId]
420
  ).then(r => r[0]);
421
+ trace.step('token_lookup_complete', token ? {
422
+ tokenId: token.id,
423
+ tokenStatus: token.status,
424
+ lockedMac: token.locked_mac,
425
+ expiresAt: token.expires_at,
426
+ } : { tokenFound: false });
427
+
428
+ if (!token) {
429
+ trace.step('token_not_found');
430
+ return res.status(404).json({ error: 'Invalid code' });
431
+ }
432
 
433
  if (token.status === 'expired' || token.status === 'revoked') {
434
+ trace.step('token_rejected_terminal_status', { tokenStatus: token.status });
435
  return res.status(410).json({ error: 'Code has expired' });
436
  }
437
 
438
  if (token.status === 'active') {
439
+ trace.step('active_token_validation_start');
440
+
441
  if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
442
+ trace.step('token_rejected_locked_mac', { lockedMac: token.locked_mac });
443
  return res.status(403).json({ error: 'Code is locked to another device' });
444
  }
445
+
446
  if (token.expires_at && new Date(token.expires_at) < new Date()) {
447
  await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
448
+ trace.step('token_marked_expired');
449
  return res.status(410).json({ error: 'Code has expired' });
450
  }
451
+
452
+ trace.step('active_token_validation_complete');
453
  }
454
 
455
  if (token.status !== 'unused' && token.status !== 'active') {
456
+ trace.step('token_rejected_invalid_state', { tokenStatus: token.status });
457
  return res.status(400).json({ error: 'Code cannot be used' });
458
  }
459
+ trace.step('token_validation_complete');
460
 
 
 
461
  try {
462
+ trace.step('omada_authorize_start');
463
  await omada.authorizeClient(siteId, mac);
464
+ trace.step('omada_authorize_complete');
465
  } catch (omadaErr) {
466
+ console.error('[portal/auth] Omada auth failed:', omadaErr.message, {
467
+ siteId,
468
+ mac,
469
+ code: normalizedCode,
470
+ totalMs: trace.total(),
471
+ });
472
  alertAdmin(
473
+ `portal-auth:${siteId}:${mac}:${normalizedCode}:omada`,
474
  formatPortalAuthAlert({
475
  deviceName: token.name || token.device_name,
476
  siteId,
 
483
  }
484
 
485
  applyRateLimitLater(siteId, mac, token);
486
+ trace.step('rate_limit_worker_dispatched');
487
 
 
488
  const now = new Date();
489
  let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
490
+ trace.step('expiry_window_prepared', { existingExpiresAt: token.expires_at });
491
 
492
  if (token.status === 'unused') {
493
  expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
 
501
  WHERE id = ?`,
502
  [mac, now, expiresAt, token.id]
503
  );
504
+ trace.step('token_activated', { expiresAt: expiresAt.toISOString() });
505
  } else if (!expiresAt) {
506
  expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
507
  await db.query(
508
  `UPDATE access_tokens SET expires_at = ? WHERE id = ?`,
509
  [expiresAt, token.id]
510
  );
511
+ trace.step('token_expiry_backfilled', { expiresAt: expiresAt.toISOString() });
512
  }
513
 
 
514
  const activeSession = await db.query(
515
  'SELECT id FROM sessions WHERE access_token_id = ? AND client_mac = ? AND is_active = 1 LIMIT 1',
516
  [token.id, mac]
517
  ).then(r => r[0]);
518
+ trace.step('session_lookup_complete', activeSession ? { sessionId: activeSession.id } : { sessionFound: false });
519
 
520
  if (activeSession) {
521
  await db.query(
 
524
  WHERE id = ?`,
525
  [activeSession.id]
526
  );
527
+ trace.step('session_refreshed', { sessionId: activeSession.id });
528
  } else {
529
  await db.query(
530
  `INSERT INTO sessions (access_token_id, client_id, device_id, client_mac, started_at, last_seen_at, is_active)
531
  VALUES (?, ?, ?, ?, NOW(), NOW(), 1)`,
532
  [token.id, token.client_id, token.device_id, mac]
533
  );
534
+ trace.step('session_created');
535
  }
536
 
537
  const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
538
  const secsLeft = Math.floor((expiresAt - now) / 1000);
539
+ trace.step('response_ready', { secsLeft, expiresAt: expiresAt.toISOString() });
540
 
541
  res.json({
542
  success: true,
 
546
  speedDown: unitLabel(token.down_limit, token.down_unit),
547
  speedUp: unitLabel(token.up_limit, token.up_unit),
548
  });
549
+ trace.step('response_sent');
550
  } catch (err) {
551
+ console.error('[portal/auth]', err.message, {
552
+ siteId,
553
+ mac,
554
+ code: normalizedCode,
555
+ totalMs: trace.total(),
556
+ });
557
  alertAdmin(
558
+ `portal-auth:${siteId}:${mac}:${normalizedCode}:fatal`,
559
  formatPortalAuthAlert({
560
  deviceName: null,
561
  siteId,
562
  mac,
563
+ code: normalizedCode,
564
  error: err.message || 'Authorization failed',
565
  })
566
  ).catch(() => {});
src/services/snippe.js CHANGED
@@ -53,6 +53,19 @@ async function createPayment({ reference, amount, phone, webhookUrl, metadata =
53
  }, ikey('pay', reference));
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  async function getPayment(reference) {
57
  return snippeRequest('GET', `/v1/payments/${reference}`);
58
  }
@@ -103,6 +116,7 @@ function verifyWebhookSignature(rawBody, headers) {
103
 
104
  module.exports = {
105
  createPayment,
 
106
  getPayment,
107
  getPayoutFee,
108
  createPayout,
 
53
  }, ikey('pay', reference));
54
  }
55
 
56
+ function extractPaymentError(err, fallbackMessage = 'Payment request failed') {
57
+ const data = err?.response?.data || {};
58
+ const message = data.message || err?.message || fallbackMessage;
59
+
60
+ return {
61
+ status: err?.response?.status || 500,
62
+ code: data.error_code || null,
63
+ message,
64
+ retryable: !(err?.response?.status >= 400 && err?.response?.status < 500),
65
+ raw: data,
66
+ };
67
+ }
68
+
69
  async function getPayment(reference) {
70
  return snippeRequest('GET', `/v1/payments/${reference}`);
71
  }
 
116
 
117
  module.exports = {
118
  createPayment,
119
+ extractPaymentError,
120
  getPayment,
121
  getPayoutFee,
122
  createPayout,