@payez/next-mvp 4.5.2 → 4.5.4

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 = {
@@ -540,7 +575,16 @@ async function checkViability(request, endpoint, log, revocationFailMode) {
540
575
  // booleans for unknown/not_configured. The booleans are read ONLY when
541
576
  // revocationStatus is ABSENT (idealresume.online's older boolean route).
542
577
  sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
543
- sessionPointer.exists = sessionStatus.exists ?? false;
578
+ // PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
579
+ // pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
580
+ // arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
581
+ // Stale is a POSITIVE signal and needs ALL of: this 2xx read, exists === false, a sessionTokenId (every
582
+ // producer sets it only when a cookie resolved to a token: routes/auth/viability.ts, api-handlers/session/
583
+ // viability.ts, refresh-viability.ts 'session_not_found'), and a session cookie on THIS request (plain or
584
+ // __Secure-). A FAILED read never gets here (exists=null -> service_error, PAY-2147); a store error throws
585
+ // inside every producer and answers 5xx or 'unknown' WITHOUT a sessionTokenId; no cookie -> no_session.
586
+ const stale = sessionStatus.exists === false && !!data.sessionTokenId && hasSessionCookie(request);
587
+ sessionPointer.exists = stale ? true : (sessionStatus.exists ?? false);
544
588
  sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
545
589
  sessionPointer.expired = data.accessTokenExpired || false;
546
590
  sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
@@ -551,12 +595,14 @@ async function checkViability(request, endpoint, log, revocationFailMode) {
551
595
  log.error('Viability check failed', { status: response.status });
552
596
  sessionStatus.exists = null;
553
597
  sessionStatus.forceInvalid = null;
598
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
554
599
  }
555
600
  }
556
601
  catch (error) {
557
602
  log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
558
603
  sessionStatus.exists = null;
559
604
  sessionStatus.forceInvalid = null;
605
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
560
606
  }
561
607
  return { sessionStatus, sessionPointer };
562
608
  }
@@ -569,7 +615,7 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
569
615
  case 'redirect':
570
616
  return redirectTo(request, decision.location, decision.clearCookies);
571
617
  case 'service_error':
572
- return server_1.NextResponse.redirect(new URL('/service-unavailable', request.url));
618
+ return serviceUnavailableResponse(request);
573
619
  case 'refresh_needed':
574
620
  // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
575
621
  // clear the session. Let it through; the real navigation refreshes.
@@ -583,7 +629,12 @@ const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
583
629
  /** Handle 'allow' decision - run RBAC if enabled */
584
630
  async function handleAllow(request, pathname, sessionPointer, sessionStatus, rbacExemptPaths = []) {
585
631
  const isPublic = (0, route_config_1.isUnauthenticatedRoute)(pathname);
586
- if ((0, rbac_check_1.isRBACEnabled)() && !isPublic && sessionPointer.exists) {
632
+ // PAY-2216: RBAC needs a session that viability CONFIRMED. A stale /api request (pointer.exists only says the
633
+ // cookie is present, status.exists is false) is allowed through so the route handler answers its own 401 - not an
634
+ // RBAC 401 here. TRUTHY, not `=== true` (NightHawk 71960): status.exists carries the producer's JSON value as-is,
635
+ // and a consumer route may answer authenticated: 1 / "true"; `=== true` skipped RBAC for those healthy sessions
636
+ // (fail-OPEN). Truthy keeps 4.5.3's RBAC coverage exactly and still excludes stale (false) and a failed read (null).
637
+ if ((0, rbac_check_1.isRBACEnabled)() && !isPublic && sessionPointer.exists && sessionStatus.exists) {
587
638
  // Skip RBAC for error/fallback pages (prevent redirect loops) and app-configured exempt paths
588
639
  if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p)) ||
589
640
  rbacExemptPaths.some(p => pathname.startsWith(p))) {
@@ -629,7 +680,9 @@ async function handleRefresh(request, safeCallback, opts) {
629
680
  // Check if circuit breaker allows refresh attempt
630
681
  if (!cb.canAttemptRefresh()) {
631
682
  log.warn('Circuit breaker preventing refresh attempt');
632
- return refreshFailureResponse(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`, 'ServiceUnavailable', true);
683
+ // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
684
+ // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
685
+ return serviceUnavailableResponse(request);
633
686
  }
634
687
  try {
635
688
  const baseUrl = (0, internal_api_url_1.getInternalApiUrl)(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.5.2",
3
+ "version": "4.5.4",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -103,8 +103,10 @@ function decide(context: AuthContext): AuthAction {
103
103
  return {
104
104
  type: 'redirect',
105
105
  location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen',
106
- reason: 'CircuitBreakerOpen',
107
- clearCookies: true
106
+ reason: 'CircuitBreakerOpen'
107
+ // PAY-2147: NO clearCookies. The breaker opening is not a verdict on the session; clearing here logged out every
108
+ // user who made a request during a blip. (Deprecated twin: it cannot express a 503 or the /service-unavailable
109
+ // page - createMvpMiddleware does, see serviceUnavailableResponse.)
108
110
  };
109
111
  }
110
112
 
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { NextRequest, NextResponse } from 'next/server';
21
21
  import { isUnauthenticatedRoute, should2FABypass } from '../auth/route-config';
22
- import { resolveCallbackDestination } from '../auth/safe-callback';
22
+ import { resolveCallbackDestination, safeCallbackPath } from '../auth/safe-callback';
23
23
  import { getInternalApiUrl } from '../edge/internal-api-url';
24
24
  import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
25
25
  import { checkPagePermission, isRBACEnabled } from './rbac-check';
@@ -290,6 +290,31 @@ function refreshFailureResponse(
290
290
  return redirectTo(request, location, clearCookies);
291
291
  }
292
292
 
293
+ /**
294
+ * PAY-2147: the terminal answer when the middleware CANNOT TELL whether the session is good
295
+ * (circuit breaker open, a half-open breaker refusing a refresh attempt, or the viability read failed).
296
+ *
297
+ * "I could not check" is not a verdict on the session, so this NEVER touches a cookie: a transient blip
298
+ * in the IdP / viability path used to wipe the session of every user who made any request while the
299
+ * breaker was open. Cookies are cleared only on a POSITIVE signal (force_invalidated, stale_session,
300
+ * pre_2fa_token_expired, refresh 401/403, explicit sign-out) - never from here.
301
+ *
302
+ * - /api/* -> 503 JSON a caller can branch on (a redirect would be followed by fetch() into a 200 HTML page).
303
+ * - pages -> /service-unavailable (public + allowDuringCircuitBreakerOpen, so it cannot loop), carrying the
304
+ * requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
305
+ * through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
306
+ */
307
+ function serviceUnavailableResponse(request: NextRequest): NextResponse {
308
+ const { pathname, search } = request.nextUrl;
309
+ if (pathname.startsWith('/api/')) {
310
+ return NextResponse.json({ error: 'ServiceUnavailable' }, { status: 503 });
311
+ }
312
+ const target = new URL('/service-unavailable', request.url);
313
+ const callback = safeCallbackPath(pathname + search);
314
+ if (callback !== '/') target.searchParams.set('callbackUrl', callback);
315
+ return NextResponse.redirect(target);
316
+ }
317
+
293
318
  // =============================================================================
294
319
  // AUTH DECISION ENGINE
295
320
  // =============================================================================
@@ -334,14 +359,11 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
334
359
  }
335
360
 
336
361
  // --- Circuit breaker open: Service degraded ---
362
+ // PAY-2147: the breaker opening says the IdP/viability path is unreachable, NOT that the session is bad.
363
+ // Answer "service unavailable" (503 for /api, /service-unavailable for pages) and leave every cookie alone.
337
364
  if (circuitBreakerOpen) {
338
365
  if (onLoginPage) return { type: 'allow' };
339
- return {
340
- type: 'redirect',
341
- location: `${LOGIN_PAGE}?error=ServiceUnavailable`,
342
- reason: 'circuit_breaker_open',
343
- clearCookies: true
344
- };
366
+ return { type: 'service_error', reason: 'circuit_breaker_open' };
345
367
  }
346
368
 
347
369
  // --- No session cookie: Need to login ---
@@ -363,6 +385,7 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
363
385
 
364
386
  // --- Service error: Can't verify session ---
365
387
  if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) {
388
+ if (onLoginPage) return { type: 'allow' }; // the login page must stay reachable while the check is down
366
389
  return { type: 'service_error', reason: 'viability_check_failed' };
367
390
  }
368
391
 
@@ -622,6 +645,18 @@ export function decideForceInvalid(
622
645
  return data.forceInvalid === true || data.revoked === true;
623
646
  }
624
647
 
648
+ /**
649
+ * PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
650
+ * Before this, a failed read left sessionPointer.exists=false, so the decision engine took its `no_session` arm
651
+ * (pages -> the LOGIN page, /api -> straight through) and its `exists === null` -> service_error arm below it was
652
+ * unreachable dead code: a user holding a perfectly good session was told to sign in during an outage. With a
653
+ * cookie present and the check down we do not know the session is bad, so the engine must say "service
654
+ * unavailable", not "sign in". No cookie -> unchanged (no_session).
655
+ */
656
+ function hasSessionCookie(request: NextRequest): boolean {
657
+ return !!(request.cookies.get(getSessionCookieName())?.value || request.cookies.get(getSecureSessionCookieName())?.value);
658
+ }
659
+
625
660
  /** Check session viability via internal API */
626
661
  async function checkViability(
627
662
  request: NextRequest,
@@ -706,7 +741,16 @@ async function checkViability(
706
741
  // booleans for unknown/not_configured. The booleans are read ONLY when
707
742
  // revocationStatus is ABSENT (idealresume.online's older boolean route).
708
743
  sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
709
- sessionPointer.exists = sessionStatus.exists ?? false;
744
+ // PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
745
+ // pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
746
+ // arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
747
+ // Stale is a POSITIVE signal and needs ALL of: this 2xx read, exists === false, a sessionTokenId (every
748
+ // producer sets it only when a cookie resolved to a token: routes/auth/viability.ts, api-handlers/session/
749
+ // viability.ts, refresh-viability.ts 'session_not_found'), and a session cookie on THIS request (plain or
750
+ // __Secure-). A FAILED read never gets here (exists=null -> service_error, PAY-2147); a store error throws
751
+ // inside every producer and answers 5xx or 'unknown' WITHOUT a sessionTokenId; no cookie -> no_session.
752
+ const stale = sessionStatus.exists === false && !!data.sessionTokenId && hasSessionCookie(request);
753
+ sessionPointer.exists = stale ? true : (sessionStatus.exists ?? false);
710
754
  sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
711
755
  sessionPointer.expired = data.accessTokenExpired || false;
712
756
  sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
@@ -716,11 +760,13 @@ async function checkViability(
716
760
  log.error('Viability check failed', { status: response.status });
717
761
  sessionStatus.exists = null;
718
762
  sessionStatus.forceInvalid = null;
763
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
719
764
  }
720
765
  } catch (error) {
721
766
  log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
722
767
  sessionStatus.exists = null;
723
768
  sessionStatus.forceInvalid = null;
769
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
724
770
  }
725
771
 
726
772
  return { sessionStatus, sessionPointer };
@@ -755,7 +801,7 @@ async function executeDecision(
755
801
  return redirectTo(request, decision.location, decision.clearCookies);
756
802
 
757
803
  case 'service_error':
758
- return NextResponse.redirect(new URL('/service-unavailable', request.url));
804
+ return serviceUnavailableResponse(request);
759
805
 
760
806
  case 'refresh_needed':
761
807
  // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
@@ -778,7 +824,12 @@ async function handleAllow(
778
824
  ): Promise<NextResponse> {
779
825
  const isPublic = isUnauthenticatedRoute(pathname);
780
826
 
781
- if (isRBACEnabled() && !isPublic && sessionPointer.exists) {
827
+ // PAY-2216: RBAC needs a session that viability CONFIRMED. A stale /api request (pointer.exists only says the
828
+ // cookie is present, status.exists is false) is allowed through so the route handler answers its own 401 - not an
829
+ // RBAC 401 here. TRUTHY, not `=== true` (NightHawk 71960): status.exists carries the producer's JSON value as-is,
830
+ // and a consumer route may answer authenticated: 1 / "true"; `=== true` skipped RBAC for those healthy sessions
831
+ // (fail-OPEN). Truthy keeps 4.5.3's RBAC coverage exactly and still excludes stale (false) and a failed read (null).
832
+ if (isRBACEnabled() && !isPublic && sessionPointer.exists && sessionStatus.exists) {
782
833
  // Skip RBAC for error/fallback pages (prevent redirect loops) and app-configured exempt paths
783
834
  if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p)) ||
784
835
  rbacExemptPaths.some(p => pathname.startsWith(p))) {
@@ -843,12 +894,9 @@ async function handleRefresh(
843
894
  // Check if circuit breaker allows refresh attempt
844
895
  if (!cb.canAttemptRefresh()) {
845
896
  log.warn('Circuit breaker preventing refresh attempt');
846
- return refreshFailureResponse(
847
- request,
848
- `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`,
849
- 'ServiceUnavailable',
850
- true
851
- );
897
+ // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
898
+ // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
899
+ return serviceUnavailableResponse(request);
852
900
  }
853
901
 
854
902
  try {