@payez/next-mvp 4.5.0 → 4.5.2

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.
@@ -46,10 +46,14 @@ exports.hashSessionToken = hashSessionToken;
46
46
  exports.resolveNumericIdpUserId = resolveNumericIdpUserId;
47
47
  exports.warnNonNumericUserId = warnNonNumericUserId;
48
48
  exports.readSessionRevocationHmac = readSessionRevocationHmac;
49
- exports.getRevocationStatus = getRevocationStatus;
49
+ exports.isConcurrentLimitBody = isConcurrentLimitBody;
50
+ exports.revocationRetryDelayMs = revocationRetryDelayMs;
50
51
  exports.getSessionStats = getSessionStats;
52
+ exports.resetRevocationCache = resetRevocationCache;
53
+ exports.getRevocationStatus = getRevocationStatus;
51
54
  const crypto_1 = __importDefault(require("crypto"));
52
55
  const token_utils_1 = require("../auth/utils/token-utils");
56
+ const token_log_id_1 = require("../lib/token-log-id");
53
57
  // -----------------------------------------------------------------------------
54
58
  // CONFIGURATION
55
59
  // -----------------------------------------------------------------------------
@@ -457,21 +461,22 @@ async function readSessionRevocationHmac(idpUserId) {
457
461
  const signingKey = getEnv('VIBE_HMAC_KEY');
458
462
  if (!idpUrl || !clientId || !signingKey) {
459
463
  // Caller distinguishes this from a read failure; report ok:false, not [].
460
- return { ok: false, sessions: [] };
464
+ return { ok: false, sessions: [], failure: 'misconfigured' };
461
465
  }
462
466
  const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
463
467
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
464
- const timestamp = Math.floor(Date.now() / 1000);
465
- const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
466
- // NOTE the absence of Authorization. This is the twin's credential.
467
- const headers = {
468
- 'Content-Type': 'application/json',
469
- 'X-Vibe-Client-Id': clientId,
470
- 'X-Vibe-Timestamp': String(timestamp),
471
- 'X-Vibe-Signature': signature,
472
- };
473
- try {
474
- const response = await fetch(proxyUrl, {
468
+ // Signed per ATTEMPT: the retry below must not reuse a timestamp that has aged.
469
+ const send = () => {
470
+ const timestamp = Math.floor(Date.now() / 1000);
471
+ const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
472
+ // NOTE the absence of Authorization. This is the twin's credential.
473
+ const headers = {
474
+ 'Content-Type': 'application/json',
475
+ 'X-Vibe-Client-Id': clientId,
476
+ 'X-Vibe-Timestamp': String(timestamp),
477
+ 'X-Vibe-Signature': signature,
478
+ };
479
+ return fetch(proxyUrl, {
475
480
  method: 'POST',
476
481
  headers,
477
482
  body: JSON.stringify({
@@ -485,10 +490,21 @@ async function readSessionRevocationHmac(idpUserId) {
485
490
  },
486
491
  }),
487
492
  });
493
+ };
494
+ try {
495
+ let response = await send();
496
+ // PAY-2136: ONE retry, and only for the transient class. The portal's own prefetch burst exceeds the free
497
+ // tier's concurrency (CONCURRENT_LIMIT, HTTP 429). Keyed on the BODY code, never on Retry-After: the IdP proxy
498
+ // relays body and status but drops that header (PAY-2143). AUTH_BLOCKED / other 429s and every 5xx are NOT
499
+ // retried (a block is not transient; a retry only adds a counted failure).
500
+ if (response.status === 429 && isConcurrentLimitBody(await safeBodyText(response))) {
501
+ await new Promise((resolve) => setTimeout(resolve, revocationRetryDelayMs(response)));
502
+ response = await send();
503
+ }
488
504
  if (!response.ok) {
489
505
  // PAY-1427: never the raw proxy body - an error page can echo the request.
490
506
  console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
491
- return { ok: false, sessions: [] };
507
+ return { ok: false, sessions: [], failure: String(response.status) }; // PAY-2136: status only, for the reason log
492
508
  }
493
509
  const data = await response.json();
494
510
  const unwrapped = (data?.data ?? data);
@@ -496,8 +512,38 @@ async function readSessionRevocationHmac(idpUserId) {
496
512
  }
497
513
  catch (error) {
498
514
  console.error('[vibe-sessions] Revocation read failed:', error);
499
- return { ok: false, sessions: [] };
515
+ return { ok: false, sessions: [], failure: 'exception' };
516
+ }
517
+ }
518
+ /** Body text of a response WITHOUT consuming the caller's copy; '' on any failure. */
519
+ async function safeBodyText(response) {
520
+ try {
521
+ return await response.clone().text();
522
+ }
523
+ catch {
524
+ return '';
525
+ }
526
+ }
527
+ /** True only for the exact code CONCURRENT_LIMIT (nested error.code, top-level code/errorCode); a non-JSON body falls back to the literal. */
528
+ function isConcurrentLimitBody(bodyText) {
529
+ try {
530
+ const b = JSON.parse(bodyText);
531
+ const nested = b?.error?.code;
532
+ const code = [nested, b?.code, b?.errorCode].find((c) => typeof c === 'string');
533
+ if (typeof code === 'string')
534
+ return code === 'CONCURRENT_LIMIT';
535
+ return false;
500
536
  }
537
+ catch {
538
+ return /CONCURRENT_LIMIT/.test(bodyText);
539
+ }
540
+ }
541
+ /** Retry-After seconds when the proxy kept it (capped at 2 s: this runs in the request path), else 200-400 ms jitter. */
542
+ function revocationRetryDelayMs(response) {
543
+ const ra = Number(response.headers?.get?.('retry-after'));
544
+ if (Number.isFinite(ra) && ra > 0)
545
+ return Math.min(ra * 1000, 2000);
546
+ return 200 + Math.floor(Math.random() * 200);
501
547
  }
502
548
  /**
503
549
  * THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
@@ -509,12 +555,15 @@ async function readSessionRevocationHmac(idpUserId) {
509
555
  * rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
510
556
  */
511
557
  function findRevokingSession(sessions, sessionTokenHash) {
558
+ return findRevokingSessionWithRule(sessions, sessionTokenHash).row;
559
+ }
560
+ function findRevokingSessionWithRule(sessions, sessionTokenHash) {
512
561
  if (sessions.length === 0)
513
- return null;
562
+ return { row: null, rule: 'none' };
514
563
  if (sessionTokenHash) {
515
564
  const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
516
565
  if (matching)
517
- return matching.status === 'revoked' ? matching : null;
566
+ return matching.status === 'revoked' ? { row: matching, rule: 'own_row_revoked' } : { row: null, rule: 'none' };
518
567
  }
519
568
  // Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
520
569
  // rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
@@ -525,11 +574,30 @@ function findRevokingSession(sessions, sessionTokenHash) {
525
574
  if (!sessions.some(s => s.status === 'active')) {
526
575
  const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
527
576
  if (revoked.length > 0)
528
- return revoked[0];
577
+ return { row: revoked[0], rule: 'legacy_no_active' };
529
578
  }
530
579
  // Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
531
580
  const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
532
- return (sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null);
581
+ const recent = sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null;
582
+ return recent ? { row: recent, rule: 'legacy_recent_2h' } : { row: null, rule: 'none' };
583
+ }
584
+ /**
585
+ * PAY-2136: one shape-only line per NON-viable revocation verdict, naming WHICH rule fired. Never a token, userId,
586
+ * hash, or row content: the rule label, a read-failure status code, a row COUNT and the session's tokenLogId.
587
+ * Must never throw into the viability path.
588
+ */
589
+ function logRevocationReason(verdict, reason, sessionToken, extra = {}) {
590
+ try {
591
+ console.warn('[REVOCATION] verdict', {
592
+ verdict,
593
+ reason,
594
+ sessionTokenId: sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : null,
595
+ ...extra,
596
+ });
597
+ }
598
+ catch {
599
+ /* diagnostic only */
600
+ }
533
601
  }
534
602
  /**
535
603
  * Determine whether a session is revoked, with an explicit UNKNOWN state
@@ -552,7 +620,7 @@ function findRevokingSession(sessions, sessionTokenHash) {
552
620
  * @param sessionToken - The live session token; hashed to match the stored row.
553
621
  * When absent, the user-wide check applies (legacy path).
554
622
  */
555
- async function getRevocationStatus(idpUserId, sessionToken) {
623
+ async function readRevocationStatus(idpUserId, sessionToken) {
556
624
  const idpUrl = getEnv('IDP_URL');
557
625
  const clientId = getEnv('VIBE_CLIENT_ID');
558
626
  const signingKey = getEnv('VIBE_HMAC_KEY');
@@ -573,16 +641,21 @@ async function getRevocationStatus(idpUserId, sessionToken) {
573
641
  // do NOT report "no sessions". 'unknown' is the fail-closed state.
574
642
  if (!idpUrl || !signingKey) {
575
643
  console.warn('[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)');
644
+ logRevocationReason('unknown', 'misconfigured', sessionToken);
576
645
  return 'unknown';
577
646
  }
578
- const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
647
+ const { ok, sessions, failure } = await readSessionRevocationHmac(idpUserId);
579
648
  if (!ok) {
580
649
  console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
650
+ logRevocationReason('unknown', `read_failed:${failure ?? 'unknown'}`, sessionToken);
581
651
  return 'unknown';
582
652
  }
583
- return findRevokingSession(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined)
584
- ? 'revoked'
585
- : 'active';
653
+ const { row, rule } = findRevokingSessionWithRule(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined);
654
+ if (row) {
655
+ logRevocationReason('revoked', rule, sessionToken, { rows: sessions.length });
656
+ return 'revoked';
657
+ }
658
+ return 'active';
586
659
  }
587
660
  // -----------------------------------------------------------------------------
588
661
  // STATISTICS
@@ -631,3 +704,65 @@ async function getSessionStats(accessToken) {
631
704
  recentLogins: stats.recentLogins,
632
705
  };
633
706
  }
707
+ // -----------------------------------------------------------------------------
708
+ // PAY-2136: SINGLE-FLIGHT + SHORT RESULT CACHE for the revocation read
709
+ // -----------------------------------------------------------------------------
710
+ // Every middleware viability check does one revocation read through the IdP proxy for the SAME Vibe client. A page
711
+ // whose chrome prefetches ~4 links makes ~8 checks inside a second; the free tier allows 2 concurrent queries, so
712
+ // the 3rd+ read was a 429 CONCURRENT_LIMIT and (before this change) signed the user out. Now concurrent callers for
713
+ // ONE session share ONE in-flight read, and a CONFIRMED verdict is reused for a few seconds.
714
+ // - keyed by userId + the token HASH: never shared across sessions or users;
715
+ // - only 'active' / 'revoked' are cached: a failure ('unknown') is shared while in flight but never remembered, so
716
+ // the next request reads again; a revoked session is honoured at most REVOCATION_CACHE_MS late;
717
+ // - REVOCATION_CACHE_MS=0 disables the cache (single-flight still applies).
718
+ const DEFAULT_REVOCATION_CACHE_MS = 5000;
719
+ const MAX_REVOCATION_CACHE_ENTRIES = 2000;
720
+ const revocationInflight = new Map();
721
+ const revocationVerdicts = new Map();
722
+ function revocationCacheMs() {
723
+ const raw = process.env.REVOCATION_CACHE_MS;
724
+ if (raw === undefined || raw === '')
725
+ return DEFAULT_REVOCATION_CACHE_MS;
726
+ const n = Number(raw);
727
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, 30_000) : DEFAULT_REVOCATION_CACHE_MS;
728
+ }
729
+ /** Test/ops hook: forget every cached verdict and in-flight read. */
730
+ function resetRevocationCache() {
731
+ revocationInflight.clear();
732
+ revocationVerdicts.clear();
733
+ }
734
+ async function getRevocationStatus(idpUserId, sessionToken) {
735
+ // No token = the user-wide legacy path: nothing session-specific to key on, so no sharing.
736
+ if (!sessionToken)
737
+ return readRevocationStatus(idpUserId, sessionToken);
738
+ const key = `${idpUserId}:${hashSessionToken(sessionToken)}`;
739
+ const ttl = revocationCacheMs();
740
+ const now = Date.now();
741
+ const hit = revocationVerdicts.get(key);
742
+ if (hit && ttl > 0 && now - hit.at < ttl)
743
+ return hit.status;
744
+ if (hit)
745
+ revocationVerdicts.delete(key);
746
+ const existing = revocationInflight.get(key);
747
+ if (existing)
748
+ return existing;
749
+ const read = readRevocationStatus(idpUserId, sessionToken)
750
+ .then((status) => {
751
+ if (ttl > 0 && (status === 'active' || status === 'revoked')) {
752
+ if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES) {
753
+ for (const [k, v] of revocationVerdicts)
754
+ if (now - v.at >= ttl)
755
+ revocationVerdicts.delete(k);
756
+ if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES)
757
+ revocationVerdicts.clear();
758
+ }
759
+ revocationVerdicts.set(key, { status, at: Date.now() });
760
+ }
761
+ return status;
762
+ })
763
+ .finally(() => {
764
+ revocationInflight.delete(key);
765
+ });
766
+ revocationInflight.set(key, read);
767
+ return read;
768
+ }