@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.
- package/dist/auth/auth-decision.d.ts +10 -1
- package/dist/auth/auth-decision.js +23 -1
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/idp-fetch.d.ts +8 -1
- package/dist/lib/idp-fetch.js +5 -2
- package/dist/lib/retry-after.d.ts +10 -0
- package/dist/lib/retry-after.js +30 -0
- package/dist/lib/session-store.d.ts +17 -0
- package/dist/lib/session-store.js +33 -0
- package/dist/lib/two-factor-verify-failure.d.ts +8 -0
- package/dist/lib/two-factor-verify-failure.js +35 -3
- package/dist/middleware/create-middleware.d.ts +10 -0
- package/dist/middleware/create-middleware.js +30 -1
- package/dist/server/auth.js +87 -3
- package/dist/vibe/sessions.d.ts +10 -22
- package/dist/vibe/sessions.js +159 -24
- package/package.json +1042 -1042
- package/scripts/check-publish-health.mjs +12 -3
- package/scripts/publish-health-tag.mjs +27 -0
- package/src/auth/auth-decision.ts +29 -1
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/idp-fetch.ts +10 -2
- package/src/lib/retry-after.ts +24 -0
- package/src/lib/session-store.ts +42 -0
- package/src/lib/two-factor-verify-failure.ts +277 -248
- package/src/middleware/create-middleware.ts +26 -1
- package/src/server/auth.ts +90 -3
- package/src/vibe/document-unwrap.ts +103 -103
- package/src/vibe/sessions.ts +172 -27
package/dist/vibe/sessions.js
CHANGED
|
@@ -46,10 +46,14 @@ exports.hashSessionToken = hashSessionToken;
|
|
|
46
46
|
exports.resolveNumericIdpUserId = resolveNumericIdpUserId;
|
|
47
47
|
exports.warnNonNumericUserId = warnNonNumericUserId;
|
|
48
48
|
exports.readSessionRevocationHmac = readSessionRevocationHmac;
|
|
49
|
-
exports.
|
|
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
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
'
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
:
|
|
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
|
+
}
|