Add portal auth timing logs and payment error handling
Browse files- public/engine.js +10 -0
- src/routes/devices.routes.js +12 -1
- src/routes/portal.routes.js +91 -16
- src/services/snippe.js +14 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 318 |
-
/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
[
|
| 379 |
).then(r => r[0]);
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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}:${
|
| 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}:${
|
| 484 |
formatPortalAuthAlert({
|
| 485 |
deviceName: null,
|
| 486 |
siteId,
|
| 487 |
mac,
|
| 488 |
-
code:
|
| 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,
|