@payez/next-mvp 4.5.2 → 4.5.3

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.
@@ -61,8 +61,10 @@ function decide(context) {
61
61
  return {
62
62
  type: 'redirect',
63
63
  location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen',
64
- reason: 'CircuitBreakerOpen',
65
- clearCookies: true
64
+ reason: 'CircuitBreakerOpen'
65
+ // PAY-2147: NO clearCookies. The breaker opening is not a verdict on the session; clearing here logged out every
66
+ // user who made a request during a blip. (Deprecated twin: it cannot express a 503 or the /service-unavailable
67
+ // page - createMvpMiddleware does, see serviceUnavailableResponse.)
66
68
  };
67
69
  }
68
70
  // No session pointer (JWT cookie) - need to login
@@ -160,6 +160,31 @@ function refreshFailureResponse(request, location, errorCode, clearCookies = fal
160
160
  }
161
161
  return redirectTo(request, location, clearCookies);
162
162
  }
163
+ /**
164
+ * PAY-2147: the terminal answer when the middleware CANNOT TELL whether the session is good
165
+ * (circuit breaker open, a half-open breaker refusing a refresh attempt, or the viability read failed).
166
+ *
167
+ * "I could not check" is not a verdict on the session, so this NEVER touches a cookie: a transient blip
168
+ * in the IdP / viability path used to wipe the session of every user who made any request while the
169
+ * breaker was open. Cookies are cleared only on a POSITIVE signal (force_invalidated, stale_session,
170
+ * pre_2fa_token_expired, refresh 401/403, explicit sign-out) - never from here.
171
+ *
172
+ * - /api/* -> 503 JSON a caller can branch on (a redirect would be followed by fetch() into a 200 HTML page).
173
+ * - pages -> /service-unavailable (public + allowDuringCircuitBreakerOpen, so it cannot loop), carrying the
174
+ * requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
175
+ * through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
176
+ */
177
+ function serviceUnavailableResponse(request) {
178
+ const { pathname, search } = request.nextUrl;
179
+ if (pathname.startsWith('/api/')) {
180
+ return server_1.NextResponse.json({ error: 'ServiceUnavailable' }, { status: 503 });
181
+ }
182
+ const target = new URL('/service-unavailable', request.url);
183
+ const callback = (0, safe_callback_1.safeCallbackPath)(pathname + search);
184
+ if (callback !== '/')
185
+ target.searchParams.set('callbackUrl', callback);
186
+ return server_1.NextResponse.redirect(target);
187
+ }
163
188
  // =============================================================================
164
189
  // AUTH DECISION ENGINE
165
190
  // =============================================================================
@@ -198,15 +223,12 @@ function makeAuthDecision(ctx) {
198
223
  return { type: 'allow' };
199
224
  }
200
225
  // --- Circuit breaker open: Service degraded ---
226
+ // PAY-2147: the breaker opening says the IdP/viability path is unreachable, NOT that the session is bad.
227
+ // Answer "service unavailable" (503 for /api, /service-unavailable for pages) and leave every cookie alone.
201
228
  if (circuitBreakerOpen) {
202
229
  if (onLoginPage)
203
230
  return { type: 'allow' };
204
- return {
205
- type: 'redirect',
206
- location: `${LOGIN_PAGE}?error=ServiceUnavailable`,
207
- reason: 'circuit_breaker_open',
208
- clearCookies: true
209
- };
231
+ return { type: 'service_error', reason: 'circuit_breaker_open' };
210
232
  }
211
233
  // --- No session cookie: Need to login ---
212
234
  if (!sessionPointer.exists) {
@@ -227,6 +249,8 @@ function makeAuthDecision(ctx) {
227
249
  }
228
250
  // --- Service error: Can't verify session ---
229
251
  if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) {
252
+ if (onLoginPage)
253
+ return { type: 'allow' }; // the login page must stay reachable while the check is down
230
254
  return { type: 'service_error', reason: 'viability_check_failed' };
231
255
  }
232
256
  // --- Session force-invalidated (admin action, password change, etc.) ---
@@ -466,6 +490,17 @@ function decideForceInvalid(data, revocationFailMode) {
466
490
  // No revocationStatus: an older boolean producer. Absence of both stays false.
467
491
  return data.forceInvalid === true || data.revoked === true;
468
492
  }
493
+ /**
494
+ * PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
495
+ * Before this, a failed read left sessionPointer.exists=false, so the decision engine took its `no_session` arm
496
+ * (pages -> the LOGIN page, /api -> straight through) and its `exists === null` -> service_error arm below it was
497
+ * unreachable dead code: a user holding a perfectly good session was told to sign in during an outage. With a
498
+ * cookie present and the check down we do not know the session is bad, so the engine must say "service
499
+ * unavailable", not "sign in". No cookie -> unchanged (no_session).
500
+ */
501
+ function hasSessionCookie(request) {
502
+ return !!(request.cookies.get((0, app_slug_1.getSessionCookieName)())?.value || request.cookies.get((0, app_slug_1.getSecureSessionCookieName)())?.value);
503
+ }
469
504
  /** Check session viability via internal API */
470
505
  async function checkViability(request, endpoint, log, revocationFailMode) {
471
506
  const sessionStatus = {
@@ -551,12 +586,14 @@ async function checkViability(request, endpoint, log, revocationFailMode) {
551
586
  log.error('Viability check failed', { status: response.status });
552
587
  sessionStatus.exists = null;
553
588
  sessionStatus.forceInvalid = null;
589
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
554
590
  }
555
591
  }
556
592
  catch (error) {
557
593
  log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
558
594
  sessionStatus.exists = null;
559
595
  sessionStatus.forceInvalid = null;
596
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
560
597
  }
561
598
  return { sessionStatus, sessionPointer };
562
599
  }
@@ -569,7 +606,7 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
569
606
  case 'redirect':
570
607
  return redirectTo(request, decision.location, decision.clearCookies);
571
608
  case 'service_error':
572
- return server_1.NextResponse.redirect(new URL('/service-unavailable', request.url));
609
+ return serviceUnavailableResponse(request);
573
610
  case 'refresh_needed':
574
611
  // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
575
612
  // clear the session. Let it through; the real navigation refreshes.
@@ -629,7 +666,9 @@ async function handleRefresh(request, safeCallback, opts) {
629
666
  // Check if circuit breaker allows refresh attempt
630
667
  if (!cb.canAttemptRefresh()) {
631
668
  log.warn('Circuit breaker preventing refresh attempt');
632
- return refreshFailureResponse(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`, 'ServiceUnavailable', true);
669
+ // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
670
+ // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
671
+ return serviceUnavailableResponse(request);
633
672
  }
634
673
  try {
635
674
  const baseUrl = (0, internal_api_url_1.getInternalApiUrl)(request);