@payez/next-mvp 4.4.9 → 4.5.1

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.
Files changed (71) hide show
  1. package/README.md +53 -0
  2. package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
  3. package/dist/api-handlers/onboarding/send-email-code.js +11 -0
  4. package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
  5. package/dist/api-handlers/onboarding/set-password.js +15 -0
  6. package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
  7. package/dist/api-handlers/onboarding/verify-code.js +14 -0
  8. package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
  9. package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
  10. package/dist/auth/auth-decision.d.ts +5 -1
  11. package/dist/auth/auth-decision.js +11 -1
  12. package/dist/auth/password-reset-bridge.d.ts +70 -0
  13. package/dist/auth/password-reset-bridge.js +99 -0
  14. package/dist/auth/route-config.js +17 -0
  15. package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
  16. package/dist/components/auth/PasswordResetBridge.js +32 -0
  17. package/dist/edge/middleware.d.ts +1 -1
  18. package/dist/edge/middleware.js +2 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/lib/onboarding-proxy.d.ts +30 -0
  22. package/dist/lib/onboarding-proxy.js +62 -0
  23. package/dist/lib/session-store.d.ts +17 -0
  24. package/dist/lib/session-store.js +33 -0
  25. package/dist/middleware/create-middleware.d.ts +10 -0
  26. package/dist/middleware/create-middleware.js +30 -1
  27. package/dist/pages/login/page.js +1 -1
  28. package/dist/pages/onboarding/index.d.ts +11 -0
  29. package/dist/pages/onboarding/index.js +20 -0
  30. package/dist/pages/onboarding/page.d.ts +1 -0
  31. package/dist/pages/onboarding/page.js +118 -0
  32. package/dist/pages/recovery/page.js +20 -7
  33. package/dist/routes/onboarding/index.d.ts +15 -0
  34. package/dist/routes/onboarding/index.js +22 -0
  35. package/dist/routes/onboarding/send-email-code.d.ts +2 -0
  36. package/dist/routes/onboarding/send-email-code.js +6 -0
  37. package/dist/routes/onboarding/set-password.d.ts +2 -0
  38. package/dist/routes/onboarding/set-password.js +6 -0
  39. package/dist/routes/onboarding/verify-code.d.ts +2 -0
  40. package/dist/routes/onboarding/verify-code.js +6 -0
  41. package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
  42. package/dist/routes/onboarding/verify-welcome-email.js +6 -0
  43. package/dist/server/auth.js +87 -3
  44. package/dist/vibe/sessions.d.ts +10 -22
  45. package/dist/vibe/sessions.js +159 -24
  46. package/package.json +56 -1
  47. package/src/api-handlers/onboarding/send-email-code.ts +18 -0
  48. package/src/api-handlers/onboarding/set-password.ts +21 -0
  49. package/src/api-handlers/onboarding/verify-code.ts +20 -0
  50. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
  51. package/src/auth/auth-decision.ts +14 -1
  52. package/src/auth/password-reset-bridge.ts +98 -0
  53. package/src/auth/route-config.ts +17 -0
  54. package/src/components/auth/PasswordResetBridge.tsx +30 -0
  55. package/src/edge/middleware.ts +1 -1
  56. package/src/index.ts +1 -1
  57. package/src/lib/onboarding-proxy.ts +57 -0
  58. package/src/lib/session-store.ts +42 -0
  59. package/src/middleware/create-middleware.ts +26 -1
  60. package/src/pages/login/page.tsx +4 -7
  61. package/src/pages/onboarding/index.ts +11 -0
  62. package/src/pages/onboarding/page.tsx +212 -0
  63. package/src/pages/recovery/page.tsx +24 -8
  64. package/src/routes/onboarding/index.ts +15 -0
  65. package/src/routes/onboarding/send-email-code.ts +2 -0
  66. package/src/routes/onboarding/set-password.ts +2 -0
  67. package/src/routes/onboarding/verify-code.ts +2 -0
  68. package/src/routes/onboarding/verify-welcome-email.ts +2 -0
  69. package/src/server/auth.ts +90 -3
  70. package/src/vibe/document-unwrap.ts +103 -103
  71. package/src/vibe/sessions.ts +172 -27
@@ -210,29 +210,14 @@ export declare function warnNonNumericUserId(sessionToken: string | undefined, r
210
210
  export declare function readSessionRevocationHmac(idpUserId: number): Promise<{
211
211
  ok: boolean;
212
212
  sessions: IVibeLoginSession[];
213
+ failure?: string;
213
214
  }>;
214
- /**
215
- * Determine whether a session is revoked, with an explicit UNKNOWN state
216
- * (PAY-1882 step 4 producer half).
217
- *
218
- * States:
219
- * 'not_configured' - this deployment has no revocation source (IDP_URL /
220
- * VIBE_CLIENT_ID / VIBE_HMAC_KEY absent). Viable; today's
221
- * behaviour. Logged once, not per request.
222
- * 'unknown' - revocation IS configured but the read FAILED (non-OK,
223
- * timeout, throw). Enforcing middleware fails this CLOSED
224
- * by default (revocationFailMode 'closed'); a Vibe-less
225
- * consumer is 'not_configured', not 'unknown', so it is not
226
- * signed out.
227
- * 'revoked' - the session's own token hash matches a revoked row, or
228
- * the user has no active session at all.
229
- * 'active' - read OK and the session is not revoked.
230
- *
231
- * @param idpUserId - The user's IDP ID
232
- * @param sessionToken - The live session token; hashed to match the stored row.
233
- * When absent, the user-wide check applies (legacy path).
234
- */
235
- export declare function getRevocationStatus(idpUserId: number, sessionToken?: string): Promise<RevocationStatus>;
215
+ /** True only for the exact code CONCURRENT_LIMIT (nested error.code, top-level code/errorCode); a non-JSON body falls back to the literal. */
216
+ export declare function isConcurrentLimitBody(bodyText: string): boolean;
217
+ /** Retry-After seconds when the proxy kept it (capped at 2 s: this runs in the request path), else 200-400 ms jitter. */
218
+ export declare function revocationRetryDelayMs(response: Response): number;
219
+ /** Which branch of the revocation decision produced the verdict (PAY-2136 reason log; never a value). */
220
+ export type RevocationRule = 'own_row_revoked' | 'legacy_no_active' | 'legacy_recent_2h' | 'none';
236
221
  /**
237
222
  * Get session statistics for admin dashboard.
238
223
  *
@@ -246,3 +231,6 @@ export declare function getSessionStats(accessToken: string): Promise<{
246
231
  byDevice: Record<string, number>;
247
232
  recentLogins: number;
248
233
  }>;
234
+ /** Test/ops hook: forget every cached verdict and in-flight read. */
235
+ export declare function resetRevocationCache(): void;
236
+ export declare function getRevocationStatus(idpUserId: number, sessionToken?: string): Promise<RevocationStatus>;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.4.9",
3
+ "version": "4.5.1",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -575,6 +575,61 @@
575
575
  "require": "./dist/pages/verify-code/page.js",
576
576
  "default": "./dist/pages/verify-code/page.js"
577
577
  },
578
+ "./dist/pages/onboarding": {
579
+ "types": "./dist/pages/onboarding/index.d.ts",
580
+ "require": "./dist/pages/onboarding/index.js",
581
+ "default": "./dist/pages/onboarding/index.js"
582
+ },
583
+ "./pages/onboarding": {
584
+ "types": "./dist/pages/onboarding/index.d.ts",
585
+ "require": "./dist/pages/onboarding/index.js",
586
+ "default": "./dist/pages/onboarding/index.js"
587
+ },
588
+ "./components/auth/PasswordResetBridge": {
589
+ "types": "./dist/components/auth/PasswordResetBridge.d.ts",
590
+ "require": "./dist/components/auth/PasswordResetBridge.js",
591
+ "default": "./dist/components/auth/PasswordResetBridge.js"
592
+ },
593
+ "./api-handlers/onboarding/verify-code": {
594
+ "types": "./dist/api-handlers/onboarding/verify-code.d.ts",
595
+ "require": "./dist/api-handlers/onboarding/verify-code.js",
596
+ "default": "./dist/api-handlers/onboarding/verify-code.js"
597
+ },
598
+ "./api-handlers/onboarding/send-email-code": {
599
+ "types": "./dist/api-handlers/onboarding/send-email-code.d.ts",
600
+ "require": "./dist/api-handlers/onboarding/send-email-code.js",
601
+ "default": "./dist/api-handlers/onboarding/send-email-code.js"
602
+ },
603
+ "./api-handlers/onboarding/verify-welcome-email": {
604
+ "types": "./dist/api-handlers/onboarding/verify-welcome-email.d.ts",
605
+ "require": "./dist/api-handlers/onboarding/verify-welcome-email.js",
606
+ "default": "./dist/api-handlers/onboarding/verify-welcome-email.js"
607
+ },
608
+ "./api-handlers/onboarding/set-password": {
609
+ "types": "./dist/api-handlers/onboarding/set-password.d.ts",
610
+ "require": "./dist/api-handlers/onboarding/set-password.js",
611
+ "default": "./dist/api-handlers/onboarding/set-password.js"
612
+ },
613
+ "./routes/onboarding/verify-code": {
614
+ "types": "./dist/routes/onboarding/verify-code.d.ts",
615
+ "require": "./dist/routes/onboarding/verify-code.js",
616
+ "default": "./dist/routes/onboarding/verify-code.js"
617
+ },
618
+ "./routes/onboarding/send-email-code": {
619
+ "types": "./dist/routes/onboarding/send-email-code.d.ts",
620
+ "require": "./dist/routes/onboarding/send-email-code.js",
621
+ "default": "./dist/routes/onboarding/send-email-code.js"
622
+ },
623
+ "./routes/onboarding/verify-welcome-email": {
624
+ "types": "./dist/routes/onboarding/verify-welcome-email.d.ts",
625
+ "require": "./dist/routes/onboarding/verify-welcome-email.js",
626
+ "default": "./dist/routes/onboarding/verify-welcome-email.js"
627
+ },
628
+ "./routes/onboarding/set-password": {
629
+ "types": "./dist/routes/onboarding/set-password.d.ts",
630
+ "require": "./dist/routes/onboarding/set-password.js",
631
+ "default": "./dist/routes/onboarding/set-password.js"
632
+ },
578
633
  "./pages/admin-login": {
579
634
  "types": "./dist/pages/admin-login/page.d.ts",
580
635
  "require": "./dist/pages/admin-login/page.js",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * POST /api/onboarding/send-email-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/send-email-code. The IdP derives the recipient from the
6
+ * token's BOUND email only, never from client input, so a tampered link cannot
7
+ * redirect the code. Also the resend leg the verify card's cooldown fires
8
+ * against.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
12
+
13
+ export async function POST(req: NextRequest) {
14
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
15
+ const onboardToken = (body as Record<string, unknown>)?.onboard_token;
16
+ if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
17
+ return onboardingProxy(req, '/api/Onboarding/send-email-code');
18
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * POST /api/onboarding/set-password (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, email, reset_token, new_password } - the locked
5
+ * contract (IdP OnboardingDtos.cs SetPasswordRequest, all four snake_case).
6
+ * email + reset_token come from the verify-code response; the IdP re-checks
7
+ * email against the token's bound user before consuming the single-use token.
8
+ * On success the IdP sets the password AND completes onboarding (issues tokens).
9
+ * Thin proxy - status and body pass through verbatim.
10
+ */
11
+ import { NextRequest } from 'next/server';
12
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
13
+
14
+ export async function POST(req: NextRequest) {
15
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
16
+ const b = body as Record<string, unknown>;
17
+ if (!b?.onboard_token) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
18
+ if (!b?.email || !b?.reset_token) return onboardingBadRequest('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
19
+ if (!b?.new_password) return onboardingBadRequest('MISSING_PASSWORD', 'new_password is required');
20
+ return onboardingProxy(req, '/api/Onboarding/set-password');
21
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * POST /api/onboarding/verify-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, code } - token-driven, no session. Thin proxy to the
5
+ * IdP's POST /api/Onboarding/verify-code, which answers
6
+ * { success, reset_token, email, client_key } on success (OnboardingDtos.cs
7
+ * VerifyCodeResponse). reset_token + email are what the set-password step needs
8
+ * next. The onboard_token IS the auth; there is no session to attach.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
12
+
13
+ export async function POST(req: NextRequest) {
14
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
15
+ const onboardToken = (body as Record<string, unknown>)?.onboard_token;
16
+ const code = (body as Record<string, unknown>)?.code;
17
+ if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
18
+ if (!code) return onboardingBadRequest('MISSING_CODE', 'code is required');
19
+ return onboardingProxy(req, '/api/Onboarding/verify-code');
20
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * POST /api/onboarding/verify-welcome-email (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/verify-welcome-email, which answers the masked contact
6
+ * info + expiry the verify card displays. The onboarding page calls this to
7
+ * render its "check your email / here is what we know" state.
8
+ */
9
+ import { NextRequest } from 'next/server';
10
+ import { onboardingBadRequest, onboardingProxy } from '../../lib/onboarding-proxy';
11
+
12
+ export async function POST(req: NextRequest) {
13
+ const body = await req.json().catch(() => ({} as Record<string, unknown>));
14
+ const onboardToken = (body as Record<string, unknown>)?.onboard_token;
15
+ if (!onboardToken) return onboardingBadRequest('MISSING_TOKEN', 'onboard_token is required');
16
+ return onboardingProxy(req, '/api/Onboarding/verify-welcome-email');
17
+ }
@@ -26,6 +26,8 @@ export interface AuthContext {
26
26
  twoFactorComplete: boolean;
27
27
  };
28
28
  circuitBreakerOpen: boolean;
29
+ /** PAY-2136: the request is a router prefetch. A prefetch never clears cookies (see isPrefetchRequest). */
30
+ isPrefetch?: boolean;
29
31
  }
30
32
 
31
33
  // Output types for the decision engine
@@ -35,9 +37,20 @@ export type AuthAction =
35
37
  | { type: 'service_error'; reason: string };
36
38
 
37
39
  /**
38
- * Main decision function - pure, testable, deterministic
40
+ * Main decision function - pure, testable, deterministic.
41
+ * PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
42
+ * redirect itself is kept, and the next real navigation reaches the same decision and clears then.
39
43
  */
40
44
  export function makeAuthDecision(context: AuthContext): AuthAction {
45
+ const action = decide(context);
46
+ if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
47
+ const { clearCookies: _dropped, ...rest } = action;
48
+ return rest;
49
+ }
50
+ return action;
51
+ }
52
+
53
+ function decide(context: AuthContext): AuthAction {
41
54
  const {
42
55
  pathname,
43
56
  isPublicRoute,
@@ -0,0 +1,98 @@
1
+ /**
2
+ * PAY-2121: the fragment bridge on `/`.
3
+ *
4
+ * The IdP's post-verify redirect for a passwordless account lands on
5
+ * ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
6
+ * - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
7
+ * @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
8
+ * and nothing happens; their reset is lost.
9
+ *
10
+ * This is the PURE half (data in, data out) so a test can execute it without a
11
+ * DOM. The client component (PasswordResetBridge) calls it on mount with
12
+ * `window.location.hash`. The kit's reset surface is the existing
13
+ * `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
14
+ * bridge rewrites the fragment into that surface's OWN query shape and navigates
15
+ * once. It invents no new reset flow.
16
+ *
17
+ * CONTRACT:
18
+ * '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
19
+ * any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
20
+ */
21
+
22
+ /** The reset surface the bridge hands off to, by its existing path. */
23
+ export const RECOVERY_PATH = '/account-auth/recovery';
24
+
25
+ /**
26
+ * PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
27
+ *
28
+ * The bridge now sends a VERIFIED passwordless user to
29
+ * /account-auth/recovery?token=<reset_token>&email=<addr>
30
+ * with a REAL reset token in hand. If the page then showed its request-a-code
31
+ * step, it would throw that token away and the flow would still dead-end - just
32
+ * one page later. So when a token is present the page must start at the
33
+ * set-password step and submit straight through resetPasswordWithToken.
34
+ *
35
+ * token present -> { step: 'set-password', resetToken: <token> }
36
+ * no token -> { step: 'initiate', resetToken: null } (today's behaviour)
37
+ */
38
+ export function recoveryEntry(
39
+ token: string | null | undefined,
40
+ email: string | null | undefined,
41
+ ): { step: 'set-password' | 'initiate'; resetToken: string | null; email: string } {
42
+ const tok = (token ?? '').trim();
43
+ return {
44
+ step: tok ? 'set-password' : 'initiate',
45
+ resetToken: tok || null,
46
+ email: (email ?? '').trim(),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
52
+ * function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
53
+ * Removing the session half (so a deep-linked reset can submit) must NOT let a
54
+ * submit with NO token reach the API carrying an empty token - which is exactly
55
+ * what a guard-less submit would do. This decides it, in one testable place:
56
+ *
57
+ * resetSubmitTarget(resetToken, sessionEmail, queryEmail):
58
+ * - no token (after trim) -> null (REFUSE: never call the API with an empty token)
59
+ * - token, session email present -> { token, email: sessionEmail }
60
+ * - token, only a query email -> { token, email: queryEmail } (the deep-link case)
61
+ * - token, but NO email anywhere -> null (REFUSE: nothing to reset)
62
+ *
63
+ * Returns the exact { token, email } the caller must pass to
64
+ * accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
65
+ */
66
+ export function resetSubmitTarget(
67
+ resetToken: string | null | undefined,
68
+ sessionEmail: string | null | undefined,
69
+ queryEmail: string | null | undefined,
70
+ ): { token: string; email: string } | null {
71
+ const tok = (resetToken ?? '').trim();
72
+ if (!tok) return null;
73
+ const email = ((sessionEmail ?? '').trim() || (queryEmail ?? '').trim());
74
+ if (!email) return null;
75
+ return { token: tok, email };
76
+ }
77
+
78
+ /**
79
+ * The redirect the fragment asks for, or null when the fragment is not a reset
80
+ * request. `hash` is `window.location.hash` (leading '#' optional).
81
+ *
82
+ * Returns null - leaves `/` untouched - when:
83
+ * - the fragment is empty;
84
+ * - `password_reset_required` is absent or not exactly 'true';
85
+ * - no `reset_token` is present (nothing to reset with; not a real link).
86
+ */
87
+ export function passwordResetRedirect(hash: string | null | undefined): string | null {
88
+ const raw = (hash ?? '').replace(/^#/, '');
89
+ if (!raw) return null;
90
+ const params = new URLSearchParams(raw);
91
+ if (params.get('password_reset_required') !== 'true') return null;
92
+ const token = params.get('reset_token') ?? '';
93
+ if (!token) return null;
94
+ const email = params.get('email') ?? '';
95
+ const qs = new URLSearchParams({ token });
96
+ if (email) qs.set('email', email);
97
+ return `${RECOVERY_PATH}?${qs.toString()}`;
98
+ }
@@ -63,6 +63,23 @@ const defaultUnauthenticatedRoutes: UnauthenticatedRouteConfig[] = [
63
63
  allowDuringCircuitBreakerOpen: true,
64
64
  requiresRateLimit: false
65
65
  },
66
+ // PAY-2121: the IdP emails an invite link to /account-auth/onboarding and a
67
+ // post-verify reset FRAGMENT on '/'. Both must be reachable unauthenticated -
68
+ // the invitee has no session (the onboard_token IS the credential), and the
69
+ // fragment lands on the already-public root. Listed explicitly, not left to
70
+ // the /account-auth/* wildcard, so the two conventional paths are visible here.
71
+ {
72
+ pattern: '/account-auth/onboarding',
73
+ description: 'IdP invite/onboarding landing page (token-driven, no session)',
74
+ allowDuringCircuitBreakerOpen: true,
75
+ requiresRateLimit: false
76
+ },
77
+ {
78
+ pattern: '/api/onboarding/*',
79
+ description: 'Onboarding API (token-driven, no session)',
80
+ allowDuringCircuitBreakerOpen: true,
81
+ requiresRateLimit: true
82
+ },
66
83
  {
67
84
  pattern: '/api/auth/*',
68
85
  description: 'Authentication API endpoints',
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { passwordResetRedirect } from '../../auth/password-reset-bridge';
5
+
6
+ /**
7
+ * PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
8
+ * redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
9
+ * (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
10
+ * the hash. With any other fragment it renders nothing and leaves `/` alone.
11
+ *
12
+ * It renders nothing and reads no user data - it only inspects the fragment.
13
+ *
14
+ * @example
15
+ * // app/page.tsx
16
+ * import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
17
+ * export default function Home() {
18
+ * return (&lt;&gt;&lt;PasswordResetBridge /&gt;&lt;/&gt;);
19
+ * }
20
+ */
21
+ export function PasswordResetBridge() {
22
+ useEffect(() => {
23
+ if (typeof window === 'undefined') return;
24
+ const target = passwordResetRedirect(window.location.hash);
25
+ if (target) window.location.replace(target);
26
+ }, []);
27
+ return null;
28
+ }
29
+
30
+ export default PasswordResetBridge;
@@ -16,7 +16,7 @@ export {
16
16
  get2FABypassConfig
17
17
  } from '../auth/route-config';
18
18
  export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
19
- export { createMvpMiddleware } from '../middleware/create-middleware';
19
+ export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
20
20
  export type {
21
21
  MvpMiddlewareOptions,
22
22
  CircuitBreakerProvider,