@payez/next-mvp 4.7.10 → 4.8.0

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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.GET = GET;
9
+ const revocation_fields_1 = require("../../auth/revocation-fields");
9
10
  const server_1 = require("next/server");
10
11
  const session_store_1 = require("../../lib/session-store");
11
12
  const auth_1 = require("../../server/auth");
@@ -45,7 +46,7 @@ async function GET(req) {
45
46
  const sessionState = await (0, auth_1.resolveSessionState)(req);
46
47
  if (sessionState.state === 'unknown') {
47
48
  console.warn('[VIABILITY] Session store unavailable - session state unknown');
48
- return server_1.NextResponse.json({ authenticated: false, revocationStatus: 'unknown', error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
49
+ return server_1.NextResponse.json({ authenticated: false, revocationStatus: 'unknown', unavailable: true, error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
49
50
  }
50
51
  if (sessionState.state === 'missing') {
51
52
  console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
@@ -174,8 +175,7 @@ async function GET(req) {
174
175
  // Session revocation (PAY-1882 step 4): the four-state status, plus the
175
176
  // boolean the middleware's existing force-invalid branch already reads.
176
177
  revocationStatus,
177
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
178
- revoked: revocationStatus === 'revoked',
178
+ ...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
179
179
  // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
180
180
  //
181
181
  // THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
@@ -17,6 +17,8 @@ export interface AuthContext {
17
17
  sessionStatus: {
18
18
  exists: boolean | null;
19
19
  forceInvalid: boolean | null;
20
+ /** PAY-2339: the producer said the revocation backend could not answer. makeAuthDecision HOLDS (no sign-out). */
21
+ unavailable?: boolean;
20
22
  requires2FA: boolean;
21
23
  twoFactorComplete: boolean;
22
24
  };
@@ -34,6 +36,7 @@ export type AuthAction = {
34
36
  } | {
35
37
  type: 'service_error';
36
38
  reason: string;
39
+ check?: 'revocation' | 'viability';
37
40
  };
38
41
  /**
39
42
  * Main decision function - pure, testable, deterministic.
@@ -94,6 +94,15 @@ function decide(context) {
94
94
  reason: 'redis_unavailable'
95
95
  };
96
96
  }
97
+ // PAY-2339 (BAPert 74633): the revocation backend could not answer. Hold: not a verdict on the session, so no
98
+ // sign-out and no cookie is touched. A caller maps this to its own "temporarily unavailable" answer (the
99
+ // createMvpMiddleware twin sends /service-unavailable or a 503 with unavailable:true, check:'revocation').
100
+ if (sessionStatus.unavailable) {
101
+ if (isLoginPage) {
102
+ return { type: 'allow' };
103
+ }
104
+ return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
105
+ }
97
106
  // Session force invalidated - need fresh login
98
107
  if (sessionStatus.forceInvalid) {
99
108
  if (isLoginPage) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
3
+ * producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
4
+ *
5
+ * forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
6
+ * unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
7
+ * three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
8
+ * instead of signing the member out. `revocationStatus` itself is emitted unchanged.
9
+ *
10
+ * 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
11
+ */
12
+ export declare function revocationFields(status: unknown): {
13
+ forceInvalid: boolean;
14
+ unavailable: boolean;
15
+ revoked: boolean;
16
+ };
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.revocationFields = revocationFields;
4
+ /**
5
+ * PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
6
+ * producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
7
+ *
8
+ * forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
9
+ * unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
10
+ * three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
11
+ * instead of signing the member out. `revocationStatus` itself is emitted unchanged.
12
+ *
13
+ * 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
14
+ */
15
+ function revocationFields(status) {
16
+ const revoked = status === 'revoked';
17
+ const unavailable = !(status === 'revoked' || status === 'active' || status === 'not_configured');
18
+ return { forceInvalid: revoked, unavailable, revoked };
19
+ }
@@ -35,9 +35,13 @@ export interface SessionPointer {
35
35
  roles: string[];
36
36
  clientId: string;
37
37
  }
38
+ /** PAY-2339: which check could not answer. A fixed, machine-readable value: consumers never parse message text. */
39
+ export type UnavailableCheck = 'revocation' | 'viability';
38
40
  export interface SessionStatus {
39
41
  exists: boolean | null;
40
42
  forceInvalid: boolean | null;
43
+ /** PAY-2339: the revocation backend could not answer and this app HOLDS (revocationFailMode 'hold'): no sign-out. */
44
+ unavailable?: boolean;
41
45
  requires2FA: boolean;
42
46
  twoFactorComplete: boolean;
43
47
  }
@@ -51,6 +55,7 @@ export type AuthAction = {
51
55
  } | {
52
56
  type: 'service_error';
53
57
  reason: string;
58
+ check?: UnavailableCheck;
54
59
  } | {
55
60
  type: 'refresh_needed';
56
61
  };
@@ -113,29 +118,41 @@ export interface MvpMiddlewareOptions {
113
118
  */
114
119
  sessionResetRoute?: boolean | string;
115
120
  /**
116
- * PAY-1882 step 4 - how this app treats a producer that reports the session's
121
+ * PAY-1882 step 4 / PAY-2339 - how this app treats a producer that reports the session's
117
122
  * revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
118
123
  * throw - the status could not be determined).
119
124
  *
120
- * 'closed' (DEFAULT) - an unknown revocation status is NOT viable; the
121
- * session is force-invalidated and the user re-auths.
122
- * This is Jon's PAY-1880 ruling (option A, fail closed),
123
- * 2026-09-27, and BAPert 66316/66320 restates it for
124
- * step 4. Consequence, accepted explicitly: a Vibe
125
- * outage signs this app's users out. 'not_configured'
126
- * also fails closed here - both live consumers ARE
127
- * configured (BAPert 66356), so for them it means a
128
- * misconfiguration, not "no revocation source".
129
- * 'open' - an unknown OR not-configured status stays viable
130
- * (availability over fail-closed). Per-app opt-out for a
131
- * deployment with no revocation source, or the two live
132
- * consumers while Jon decides.
125
+ * 'hold' (DEFAULT since PAY-2339, Jon 2026-10-13, option C) - the member is NOT signed out. The request is
126
+ * answered "temporarily unavailable": a page goes to /service-unavailable?callbackUrl=
127
+ * (the session cookie is NOT touched, so "Try again" returns the member still signed in);
128
+ * an /api request gets a 503 carrying `unavailable: true` and `check: 'revocation'`, a
129
+ * fixed machine-readable value. Nothing protected is served while the answer is unknown.
130
+ * Each held request logs one countable `revocation_unavailable` line.
131
+ * THIS REPLACES THE OLD DEFAULT: the PAY-1880 ruling (Jon, 2026-09-27, option A) made
132
+ * 'closed' the default and accepted that a Vibe outage signs an app's users out. That
133
+ * sentence no longer applies to 'unknown' by default: a Vibe outage no longer signs members
134
+ * out unless the app asks for 'closed'. 'revoked' is unchanged.
135
+ * 'closed' - an unknown revocation status is NOT viable; the session is force-invalidated and the
136
+ * user re-auths (TODAY'S sign-out, now opt-in by name). Consequence, accepted explicitly:
137
+ * a Vibe outage signs this app's users out.
138
+ * 'open' - an unknown status stays viable (availability over fail-closed). Per-app opt-out for a
139
+ * deployment with no revocation source.
133
140
  *
134
- * Neither mode changes `'revoked'` (always force-invalid) or an ABSENT
135
- * `revocationStatus` (an older producer - unchanged behaviour, so no consumer
136
- * is signed out for not implementing the check).
141
+ * `'not_configured'` is UNCHANGED under the default: it is a configuration fault, not an outage, so it still
142
+ * force-invalidates exactly as before (a "try again" page would be false); `noRevocationSource` is the only way
143
+ * to make it viable. Neither mode changes `'revoked'` (always force-invalid, unchanged) or an ABSENT
144
+ * `revocationStatus` (an older producer - unchanged behaviour, so no consumer is signed out for not
145
+ * implementing the check). A consumer that wrote 'closed' or 'open' by name keeps exactly what it asked for.
137
146
  */
138
- revocationFailMode?: 'closed' | 'open';
147
+ revocationFailMode?: 'hold' | 'closed' | 'open';
148
+ /**
149
+ * PAY-2339 (BAPert 74640): where a page request goes while a revocation answer is held. DEFAULT (absent): the
150
+ * middleware answers inline with a minimal 503 HTML page ("Temporarily unavailable", a "Try again" link to the safe
151
+ * callback, no Set-Cookie), so a consumer that never added a page cannot get a 404. Name a same-site path (e.g.
152
+ * '/service-unavailable') to redirect there instead with `?callbackUrl=`; the consumer then mounts
153
+ * `export { default } from '@payez/next-mvp/pages/service-unavailable'` (or its own page) at that route.
154
+ */
155
+ unavailablePage?: string;
139
156
  /**
140
157
  * PAY-2302 cut 1c (BAPert 73585): this app has NO revocation source - for example a development environment
141
158
  * provisioned without a vibe client id / HMAC key - and says so EXPLICITLY. Under it a viability answer of
@@ -173,8 +190,21 @@ export declare function createMvpMiddleware(options?: MvpMiddlewareOptions): (re
173
190
  * 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
174
191
  * absent (idealresume.online's older boolean-only route).
175
192
  */
193
+ export type RevocationOutcome = 'viable' | 'invalid' | 'hold';
194
+ /**
195
+ * PAY-2339: the three-way form of the revocation decision. `decideForceInvalid` below is kept as its boolean view.
196
+ *
197
+ * 'invalid' - sign the member out (cookies cleared, login page)
198
+ * 'hold' - the backend could not answer and this app holds: no sign-out, answer "temporarily unavailable"
199
+ * 'viable' - carry on
200
+ */
201
+ export declare function decideRevocationOutcome(data: {
202
+ revocationStatus?: unknown;
203
+ forceInvalid?: unknown;
204
+ revoked?: unknown;
205
+ }, revocationFailMode: 'hold' | 'closed' | 'open', noRevocationSource?: boolean): RevocationOutcome;
176
206
  export declare function decideForceInvalid(data: {
177
207
  revocationStatus?: unknown;
178
208
  forceInvalid?: unknown;
179
209
  revoked?: unknown;
180
- }, revocationFailMode: 'closed' | 'open', noRevocationSource?: boolean): boolean;
210
+ }, revocationFailMode: 'hold' | 'closed' | 'open', noRevocationSource?: boolean): boolean;
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.TWO_FACTOR_TIMEOUT_ERROR = void 0;
22
22
  exports.isPrefetchRequest = isPrefetchRequest;
23
23
  exports.createMvpMiddleware = createMvpMiddleware;
24
+ exports.decideRevocationOutcome = decideRevocationOutcome;
24
25
  exports.decideForceInvalid = decideForceInvalid;
25
26
  const server_1 = require("next/server");
26
27
  const route_config_1 = require("../auth/route-config");
@@ -185,13 +186,31 @@ async function refreshFailureResponse(request, location, errorCode, clearCookies
185
186
  * requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
186
187
  * through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
187
188
  */
188
- function serviceUnavailableResponse(request) {
189
+ function serviceUnavailableResponse(request, check = 'viability', unavailablePage) {
189
190
  const { pathname, search } = request.nextUrl;
190
191
  if (pathname.startsWith('/api/')) {
191
- return server_1.NextResponse.json({ error: 'ServiceUnavailable' }, { status: 503 });
192
+ // PAY-2339 P1/P12: one fixed machine-readable shape for every "could not check" answer. No protected data.
193
+ return server_1.NextResponse.json({ error: 'ServiceUnavailable', unavailable: true, check }, { status: 503 });
192
194
  }
193
- const target = new URL('/service-unavailable', request.url);
194
195
  const callback = (0, safe_callback_1.safeCallbackPath)(pathname + search);
196
+ // PAY-2339 (BAPert 74640, P13): a HELD revocation answer needs no page in the consumer. Unless the app names its own
197
+ // page (`unavailablePage`), the middleware answers inline: a minimal 503 HTML, a "try again" link to the safe
198
+ // callback, and no Set-Cookie (nothing here touches the session). No redirect, so no 404 is possible.
199
+ // `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
200
+ // scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
201
+ // The named page applies to the revocation hold ONLY (BAPert 74648: the viability-failure arm is untouched). If the
202
+ // request is already FOR the named page (an app that did not register it as public), answer inline: redirecting to
203
+ // it again would loop with a longer callbackUrl each time (QAPert 74651).
204
+ const named = check === 'revocation' && unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
205
+ if (check === 'revocation' && (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, ''))) {
206
+ const href = callback.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
207
+ return new server_1.NextResponse('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Temporarily unavailable</title>' +
208
+ '<main role="status" style="font-family:system-ui,sans-serif;max-width:28rem;margin:20vh auto;padding:0 1rem;text-align:center">' +
209
+ '<h1 style="font-size:1.25rem">Temporarily unavailable</h1>' +
210
+ '<p>We could not check your session just now. You are still signed in.</p>' +
211
+ '<p><a href="' + href + '">Try again</a></p></main>', { status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' } });
212
+ }
213
+ const target = new URL(named || '/service-unavailable', request.url);
195
214
  if (callback !== '/')
196
215
  target.searchParams.set('callbackUrl', callback);
197
216
  return server_1.NextResponse.redirect(target);
@@ -264,6 +283,14 @@ function makeAuthDecision(ctx) {
264
283
  return { type: 'allow' }; // the login page must stay reachable while the check is down
265
284
  return { type: 'service_error', reason: 'viability_check_failed' };
266
285
  }
286
+ // --- PAY-2339: the revocation backend could not answer and this app HOLDS (the default) ---
287
+ // Not a verdict on the session: no cookie is touched, nothing protected is served, and the answer says why in a
288
+ // fixed machine-readable form. The login page stays reachable.
289
+ if (sessionStatus.unavailable) {
290
+ if (onLoginPage)
291
+ return { type: 'allow' };
292
+ return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
293
+ }
267
294
  // --- Session force-invalidated (admin action, password change, etc.) ---
268
295
  if (sessionStatus.forceInvalid) {
269
296
  if (onLoginPage)
@@ -380,8 +407,9 @@ function createMvpMiddleware(options = {}) {
380
407
  const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
381
408
  const bypassPaths = options.bypassPaths || [];
382
409
  const rbacExemptPaths = options.rbacExemptPaths || [];
383
- // PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
384
- const revocationFailMode = options.revocationFailMode || 'closed';
410
+ // PAY-2339: the default is 'hold' (Jon 2026-10-13, option C): an unknown revocation answer does not sign the member
411
+ // out. PAY-1882 step 4 had 'closed' (Jon's PAY-1880 option A, BAPert 66316); it is now opt-in by name.
412
+ const revocationFailMode = options.revocationFailMode || 'hold';
385
413
  // PAY-2302 cut 1c: strictly `=== true`, so a truthy string in a config file cannot switch revocation off.
386
414
  const noRevocationSource = options.noRevocationSource === true;
387
415
  if (noRevocationSource) {
@@ -499,42 +527,44 @@ function createMvpMiddleware(options = {}) {
499
527
  refreshEndpoint,
500
528
  rbacExemptPaths,
501
529
  reset: resolveReset(),
530
+ unavailablePage: options.unavailablePage,
502
531
  onRefreshSuccess: options.onRefreshSuccess,
503
532
  onRefreshFailure: options.onRefreshFailure,
504
533
  });
505
534
  };
506
535
  }
507
536
  /**
508
- * The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
509
- *
510
- * Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
511
- * that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
512
- * producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
537
+ * PAY-2339: the three-way form of the revocation decision. `decideForceInvalid` below is kept as its boolean view.
513
538
  *
514
- * RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
515
- * decision is made from it plus failMode, and the folded booleans are ignored for
516
- * 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
517
- * absent (idealresume.online's older boolean-only route).
539
+ * 'invalid' - sign the member out (cookies cleared, login page)
540
+ * 'hold' - the backend could not answer and this app holds: no sign-out, answer "temporarily unavailable"
541
+ * 'viable' - carry on
518
542
  */
519
- function decideForceInvalid(data, revocationFailMode, noRevocationSource = false) {
543
+ function decideRevocationOutcome(data, revocationFailMode, noRevocationSource = false) {
520
544
  const status = data.revocationStatus;
521
545
  if (typeof status === 'string') {
522
546
  if (status === 'revoked')
523
- return true;
547
+ return 'invalid';
524
548
  if (status === 'active')
525
- return false;
549
+ return 'viable';
526
550
  // PAY-2302 cut 1c: an app that declared it has no revocation source. ONLY the exact 'not_configured' string.
527
551
  if (status === 'not_configured' && noRevocationSource === true)
528
- return false;
529
- // S1 (NightHawk 66409): ANY other string - 'unknown', 'not_configured', a
530
- // typo like 'Revoked', 'error', or '' - is treated as 'unknown' (fail mode
531
- // applies), NEVER as 'active'. Returning false here would let the first
532
- // producer that emits an unexpected state silently switch revocation OFF -
533
- // the exact PAY-1880 fail-open class. Only an explicit 'active' is viable.
534
- return revocationFailMode === 'closed';
552
+ return 'viable';
553
+ // PAY-2339: 'not_configured' is a configuration fault, not an outage. Under the default it stays signed out
554
+ // exactly as before; only 'open' relaxes it (a Vibe-less app).
555
+ if (status === 'not_configured')
556
+ return revocationFailMode === 'open' ? 'viable' : 'invalid';
557
+ // S1 (NightHawk 66409): ANY other string - 'unknown', a typo like 'Revoked', 'error', or '' - is treated as
558
+ // 'unknown' (the fail mode applies), NEVER as 'active'. Only an explicit 'active' is viable.
559
+ if (revocationFailMode === 'open')
560
+ return 'viable';
561
+ return revocationFailMode === 'closed' ? 'invalid' : 'hold';
535
562
  }
536
- // No revocationStatus: an older boolean producer. Absence of both stays false.
537
- return data.forceInvalid === true || data.revoked === true;
563
+ // No revocationStatus: an older boolean producer. Absence of both stays viable.
564
+ return data.forceInvalid === true || data.revoked === true ? 'invalid' : 'viable';
565
+ }
566
+ function decideForceInvalid(data, revocationFailMode, noRevocationSource = false) {
567
+ return decideRevocationOutcome(data, revocationFailMode, noRevocationSource) === 'invalid';
538
568
  }
539
569
  /**
540
570
  * PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
@@ -620,7 +650,13 @@ async function checkViability(request, endpoint, log, revocationFailMode, noRevo
620
650
  // revocations status, decide from it + failMode and ignore the folded
621
651
  // booleans for unknown/not_configured. The booleans are read ONLY when
622
652
  // revocationStatus is ABSENT (idealresume.online's older boolean route).
623
- sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode, noRevocationSource);
653
+ const outcome = decideRevocationOutcome(data, revocationFailMode, noRevocationSource);
654
+ sessionStatus.forceInvalid = outcome === 'invalid';
655
+ if (outcome === 'hold') {
656
+ sessionStatus.unavailable = true;
657
+ // PAY-2339 P11: one countable line per held request, naming the check. No session value, no address.
658
+ log.warn('revocation_unavailable', { check: 'revocation' });
659
+ }
624
660
  // PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
625
661
  // pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
626
662
  // arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
@@ -663,7 +699,7 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
663
699
  ? clearingRedirect(request, decision.location, opts.reset)
664
700
  : redirectTo(request, decision.location);
665
701
  case 'service_error':
666
- return serviceUnavailableResponse(request);
702
+ return serviceUnavailableResponse(request, decision.check ?? 'viability', opts.unavailablePage);
667
703
  case 'refresh_needed':
668
704
  // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
669
705
  // clear the session. Let it through; the real navigation refreshes.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
3
+ * check (or the viability read) could not answer. Mount it in one line in the consuming app:
4
+ *
5
+ * // app/service-unavailable/page.tsx
6
+ * export { default } from '@payez/next-mvp/pages/service-unavailable';
7
+ *
8
+ * It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
9
+ * consumer may replace the page (and the wording) with its own at the same route.
10
+ *
11
+ * `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
12
+ * a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
13
+ */
14
+ export declare function safeReturnPath(raw: string | null | undefined): string;
15
+ export default function ServiceUnavailablePage(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.safeReturnPath = safeReturnPath;
5
+ exports.default = ServiceUnavailablePage;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("react");
8
+ const navigation_1 = require("next/navigation");
9
+ /**
10
+ * PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
11
+ * check (or the viability read) could not answer. Mount it in one line in the consuming app:
12
+ *
13
+ * // app/service-unavailable/page.tsx
14
+ * export { default } from '@payez/next-mvp/pages/service-unavailable';
15
+ *
16
+ * It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
17
+ * consumer may replace the page (and the wording) with its own at the same route.
18
+ *
19
+ * `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
20
+ * a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
21
+ */
22
+ function safeReturnPath(raw) {
23
+ if (!raw || typeof raw !== 'string')
24
+ return '/';
25
+ if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\'))
26
+ return '/';
27
+ if (/[\u0000-\u001f]/.test(raw))
28
+ return '/';
29
+ return raw;
30
+ }
31
+ function ServiceUnavailableContent() {
32
+ const params = (0, navigation_1.useSearchParams)();
33
+ const back = safeReturnPath(params?.get('callbackUrl'));
34
+ return ((0, jsx_runtime_1.jsx)("div", { className: "min-h-screen flex items-center justify-center p-4", children: (0, jsx_runtime_1.jsxs)("div", { role: "status", className: "max-w-md w-full text-center rounded-xl p-8 shadow-lg border", style: {
35
+ backgroundColor: 'var(--bg-card, #ffffff)',
36
+ borderColor: 'var(--border-default, #e5e7eb)',
37
+ }, children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-xl font-semibold mb-2", style: { color: 'var(--text-primary, #111827)' }, children: "Temporarily unavailable" }), (0, jsx_runtime_1.jsx)("p", { className: "mb-6", style: { color: 'var(--text-secondary, #6b7280)' }, children: "We could not check your session just now. You are still signed in. Please try again in a moment." }), (0, jsx_runtime_1.jsx)("a", { href: back, className: "inline-block rounded-lg px-4 py-2 font-medium", style: { background: 'var(--color-primary, #10b981)', color: 'white' }, children: "Try again" })] }) }));
38
+ }
39
+ function ServiceUnavailablePage() {
40
+ return ((0, jsx_runtime_1.jsx)(react_1.Suspense, { fallback: null, children: (0, jsx_runtime_1.jsx)(ServiceUnavailableContent, {}) }));
41
+ }
@@ -28,6 +28,7 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
28
28
  needsRefresh: boolean;
29
29
  authenticated: boolean;
30
30
  revocationStatus: RevocationStatus;
31
+ unavailable: boolean;
31
32
  error: string;
32
33
  code: string;
33
34
  }> | NextResponse<{
@@ -36,15 +37,6 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
36
37
  authenticated: boolean;
37
38
  reason: string;
38
39
  }> | NextResponse<{
39
- viable: boolean;
40
- needsRefresh: boolean;
41
- expiresIn: number;
42
- hasRefreshToken: boolean;
43
- authenticated: boolean;
44
- sessionTokenId: string | null;
45
- revocationStatus: RevocationStatus;
46
- forceInvalid: boolean;
47
- revoked: boolean;
48
40
  tenantRequiresTwoFactor: boolean;
49
41
  userHasCompletedTenantTwoFactorRequirements: boolean;
50
42
  userStillNeedsTwoFactor: boolean;
@@ -56,4 +48,22 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
56
48
  expiresAt: string;
57
49
  roles: string[];
58
50
  clientId: string | null;
51
+ forceInvalid: boolean;
52
+ unavailable: boolean;
53
+ revoked: boolean;
54
+ viable: boolean;
55
+ needsRefresh: boolean;
56
+ expiresIn: number;
57
+ hasRefreshToken: boolean;
58
+ authenticated: boolean;
59
+ sessionTokenId: string | null;
60
+ revocationStatus: RevocationStatus;
61
+ }> | NextResponse<{
62
+ viable: boolean;
63
+ needsRefresh: boolean;
64
+ authenticated: boolean;
65
+ revocationStatus: RevocationStatus;
66
+ error: string;
67
+ code: string;
68
+ correlationId: string;
59
69
  }>>;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.GET = GET;
19
+ const revocation_fields_1 = require("../../auth/revocation-fields");
19
20
  const server_1 = require("next/server");
20
21
  const auth_1 = require("../../server/auth");
21
22
  const session_store_1 = require("../../lib/session-store");
@@ -150,6 +151,7 @@ async function GET(req) {
150
151
  needsRefresh: false,
151
152
  authenticated: false,
152
153
  revocationStatus: 'unknown',
154
+ unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
153
155
  error: 'Session store unavailable',
154
156
  code: 'STORE_UNAVAILABLE'
155
157
  }, { status: 500 });
@@ -361,8 +363,7 @@ async function GET(req) {
361
363
  // Session revocation (PAY-1882 step 4): the four-state status, plus the
362
364
  // boolean the middleware's existing force-invalid branch already reads.
363
365
  revocationStatus,
364
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
365
- revoked: revocationStatus === 'revoked',
366
+ ...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
366
367
  // Clear names for middleware decision-making
367
368
  tenantRequiresTwoFactor,
368
369
  userHasCompletedTenantTwoFactorRequirements,
@@ -436,8 +437,7 @@ async function GET(req) {
436
437
  reason: 'Token expired',
437
438
  // Session revocation (PAY-1882 step 4) - same value on every return.
438
439
  revocationStatus,
439
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
440
- revoked: revocationStatus === 'revoked',
440
+ ...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
441
441
  // RBAC fields
442
442
  roles: session?.roles || [],
443
443
  clientId: resolveClientId(session?.idpClientId),
@@ -452,8 +452,7 @@ async function GET(req) {
452
452
  sessionTokenId: sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : null,
453
453
  // Session revocation (PAY-1882 step 4) - same value on every return.
454
454
  revocationStatus,
455
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
456
- revoked: revocationStatus === 'revoked',
455
+ ...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
457
456
  // Clear names
458
457
  tenantRequiresTwoFactor,
459
458
  userHasCompletedTenantTwoFactorRequirements,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.7.10",
3
+ "version": "4.8.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -910,6 +910,11 @@
910
910
  "require": "./dist/pages/client-admin/index.js",
911
911
  "default": "./dist/pages/client-admin/index.js"
912
912
  },
913
+ "./pages/service-unavailable": {
914
+ "types": "./dist/pages/service-unavailable/page.d.ts",
915
+ "require": "./dist/pages/service-unavailable/page.js",
916
+ "default": "./dist/pages/service-unavailable/page.js"
917
+ },
913
918
  "./pages/coming-soon": {
914
919
  "types": "./dist/pages/coming-soon/page.d.ts",
915
920
  "require": "./dist/pages/coming-soon/page.js",
@@ -4,6 +4,7 @@
4
4
  * This API route is called by the middleware to securely check if a session is valid.
5
5
  */
6
6
 
7
+ import { revocationFields } from '../../auth/revocation-fields';
7
8
  import { NextRequest, NextResponse } from 'next/server';
8
9
  import { getSession as getRedisSession, getBetterAuthSession } from '../../lib/session-store';
9
10
  import { resolveSessionState } from '../../server/auth';
@@ -52,7 +53,7 @@ export async function GET(req: NextRequest) {
52
53
  if (sessionState.state === 'unknown') {
53
54
  console.warn('[VIABILITY] Session store unavailable - session state unknown');
54
55
  return NextResponse.json(
55
- { authenticated: false, revocationStatus: 'unknown' as RevocationStatus, error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' },
56
+ { authenticated: false, revocationStatus: 'unknown' as RevocationStatus, unavailable: true, error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' },
56
57
  { status: 500 }
57
58
  );
58
59
  }
@@ -192,8 +193,7 @@ export async function GET(req: NextRequest) {
192
193
  // Session revocation (PAY-1882 step 4): the four-state status, plus the
193
194
  // boolean the middleware's existing force-invalid branch already reads.
194
195
  revocationStatus,
195
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
196
- revoked: revocationStatus === 'revoked',
196
+ ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
197
197
  // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
198
198
  //
199
199
  // THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
@@ -22,6 +22,8 @@ export interface AuthContext {
22
22
  sessionStatus: {
23
23
  exists: boolean | null; // null = check failed/timeout
24
24
  forceInvalid: boolean | null; // null = check failed/timeout
25
+ /** PAY-2339: the producer said the revocation backend could not answer. makeAuthDecision HOLDS (no sign-out). */
26
+ unavailable?: boolean;
25
27
  requires2FA: boolean;
26
28
  twoFactorComplete: boolean;
27
29
  };
@@ -34,7 +36,7 @@ export interface AuthContext {
34
36
  export type AuthAction =
35
37
  | { type: 'allow' }
36
38
  | { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
37
- | { type: 'service_error'; reason: string };
39
+ | { type: 'service_error'; reason: string; check?: 'revocation' | 'viability' };
38
40
 
39
41
  /** PAY-2180: warn once per process, not per request. */
40
42
  let deprecationWarned = false;
@@ -139,6 +141,16 @@ function decide(context: AuthContext): AuthAction {
139
141
  };
140
142
  }
141
143
 
144
+ // PAY-2339 (BAPert 74633): the revocation backend could not answer. Hold: not a verdict on the session, so no
145
+ // sign-out and no cookie is touched. A caller maps this to its own "temporarily unavailable" answer (the
146
+ // createMvpMiddleware twin sends /service-unavailable or a 503 with unavailable:true, check:'revocation').
147
+ if (sessionStatus.unavailable) {
148
+ if (isLoginPage) {
149
+ return { type: 'allow' };
150
+ }
151
+ return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
152
+ }
153
+
142
154
  // Session force invalidated - need fresh login
143
155
  if (sessionStatus.forceInvalid) {
144
156
  if (isLoginPage) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
3
+ * producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
4
+ *
5
+ * forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
6
+ * unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
7
+ * three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
8
+ * instead of signing the member out. `revocationStatus` itself is emitted unchanged.
9
+ *
10
+ * 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
11
+ */
12
+ export function revocationFields(status: unknown): { forceInvalid: boolean; unavailable: boolean; revoked: boolean } {
13
+ const revoked = status === 'revoked';
14
+ const unavailable = !(status === 'revoked' || status === 'active' || status === 'not_configured');
15
+ return { forceInvalid: revoked, unavailable, revoked };
16
+ }
@@ -57,9 +57,14 @@ export interface SessionPointer {
57
57
  clientId: string;
58
58
  }
59
59
 
60
+ /** PAY-2339: which check could not answer. A fixed, machine-readable value: consumers never parse message text. */
61
+ export type UnavailableCheck = 'revocation' | 'viability';
62
+
60
63
  export interface SessionStatus {
61
64
  exists: boolean | null; // null = check failed/timeout
62
65
  forceInvalid: boolean | null; // null = check failed/timeout
66
+ /** PAY-2339: the revocation backend could not answer and this app HOLDS (revocationFailMode 'hold'): no sign-out. */
67
+ unavailable?: boolean;
63
68
  requires2FA: boolean;
64
69
  twoFactorComplete: boolean;
65
70
  }
@@ -67,7 +72,7 @@ export interface SessionStatus {
67
72
  export type AuthAction =
68
73
  | { type: 'allow' }
69
74
  | { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
70
- | { type: 'service_error'; reason: string }
75
+ | { type: 'service_error'; reason: string; check?: UnavailableCheck }
71
76
  | { type: 'refresh_needed' };
72
77
 
73
78
  // =============================================================================
@@ -135,29 +140,42 @@ export interface MvpMiddlewareOptions {
135
140
  */
136
141
  sessionResetRoute?: boolean | string;
137
142
  /**
138
- * PAY-1882 step 4 - how this app treats a producer that reports the session's
143
+ * PAY-1882 step 4 / PAY-2339 - how this app treats a producer that reports the session's
139
144
  * revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
140
145
  * throw - the status could not be determined).
141
146
  *
142
- * 'closed' (DEFAULT) - an unknown revocation status is NOT viable; the
143
- * session is force-invalidated and the user re-auths.
144
- * This is Jon's PAY-1880 ruling (option A, fail closed),
145
- * 2026-09-27, and BAPert 66316/66320 restates it for
146
- * step 4. Consequence, accepted explicitly: a Vibe
147
- * outage signs this app's users out. 'not_configured'
148
- * also fails closed here - both live consumers ARE
149
- * configured (BAPert 66356), so for them it means a
150
- * misconfiguration, not "no revocation source".
151
- * 'open' - an unknown OR not-configured status stays viable
152
- * (availability over fail-closed). Per-app opt-out for a
153
- * deployment with no revocation source, or the two live
154
- * consumers while Jon decides.
147
+ * 'hold' (DEFAULT since PAY-2339, Jon 2026-10-13, option C) - the member is NOT signed out. The request is
148
+ * answered "temporarily unavailable": a page goes to /service-unavailable?callbackUrl=
149
+ * (the session cookie is NOT touched, so "Try again" returns the member still signed in);
150
+ * an /api request gets a 503 carrying `unavailable: true` and `check: 'revocation'`, a
151
+ * fixed machine-readable value. Nothing protected is served while the answer is unknown.
152
+ * Each held request logs one countable `revocation_unavailable` line.
153
+ * THIS REPLACES THE OLD DEFAULT: the PAY-1880 ruling (Jon, 2026-09-27, option A) made
154
+ * 'closed' the default and accepted that a Vibe outage signs an app's users out. That
155
+ * sentence no longer applies to 'unknown' by default: a Vibe outage no longer signs members
156
+ * out unless the app asks for 'closed'. 'revoked' is unchanged.
157
+ * 'closed' - an unknown revocation status is NOT viable; the session is force-invalidated and the
158
+ * user re-auths (TODAY'S sign-out, now opt-in by name). Consequence, accepted explicitly:
159
+ * a Vibe outage signs this app's users out.
160
+ * 'open' - an unknown status stays viable (availability over fail-closed). Per-app opt-out for a
161
+ * deployment with no revocation source.
155
162
  *
156
- * Neither mode changes `'revoked'` (always force-invalid) or an ABSENT
157
- * `revocationStatus` (an older producer - unchanged behaviour, so no consumer
158
- * is signed out for not implementing the check).
163
+ * `'not_configured'` is UNCHANGED under the default: it is a configuration fault, not an outage, so it still
164
+ * force-invalidates exactly as before (a "try again" page would be false); `noRevocationSource` is the only way
165
+ * to make it viable. Neither mode changes `'revoked'` (always force-invalid, unchanged) or an ABSENT
166
+ * `revocationStatus` (an older producer - unchanged behaviour, so no consumer is signed out for not
167
+ * implementing the check). A consumer that wrote 'closed' or 'open' by name keeps exactly what it asked for.
168
+ */
169
+ revocationFailMode?: 'hold' | 'closed' | 'open';
170
+
171
+ /**
172
+ * PAY-2339 (BAPert 74640): where a page request goes while a revocation answer is held. DEFAULT (absent): the
173
+ * middleware answers inline with a minimal 503 HTML page ("Temporarily unavailable", a "Try again" link to the safe
174
+ * callback, no Set-Cookie), so a consumer that never added a page cannot get a 404. Name a same-site path (e.g.
175
+ * '/service-unavailable') to redirect there instead with `?callbackUrl=`; the consumer then mounts
176
+ * `export { default } from '@payez/next-mvp/pages/service-unavailable'` (or its own page) at that route.
159
177
  */
160
- revocationFailMode?: 'closed' | 'open';
178
+ unavailablePage?: string;
161
179
 
162
180
  /**
163
181
  * PAY-2302 cut 1c (BAPert 73585): this app has NO revocation source - for example a development environment
@@ -353,13 +371,34 @@ async function refreshFailureResponse(
353
371
  * requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
354
372
  * through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
355
373
  */
356
- function serviceUnavailableResponse(request: NextRequest): NextResponse {
374
+ function serviceUnavailableResponse(request: NextRequest, check: UnavailableCheck = 'viability', unavailablePage?: string): NextResponse {
357
375
  const { pathname, search } = request.nextUrl;
358
376
  if (pathname.startsWith('/api/')) {
359
- return NextResponse.json({ error: 'ServiceUnavailable' }, { status: 503 });
377
+ // PAY-2339 P1/P12: one fixed machine-readable shape for every "could not check" answer. No protected data.
378
+ return NextResponse.json({ error: 'ServiceUnavailable', unavailable: true, check }, { status: 503 });
360
379
  }
361
- const target = new URL('/service-unavailable', request.url);
362
380
  const callback = safeCallbackPath(pathname + search);
381
+ // PAY-2339 (BAPert 74640, P13): a HELD revocation answer needs no page in the consumer. Unless the app names its own
382
+ // page (`unavailablePage`), the middleware answers inline: a minimal 503 HTML, a "try again" link to the safe
383
+ // callback, and no Set-Cookie (nothing here touches the session). No redirect, so no 404 is possible.
384
+ // `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
385
+ // scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
386
+ // The named page applies to the revocation hold ONLY (BAPert 74648: the viability-failure arm is untouched). If the
387
+ // request is already FOR the named page (an app that did not register it as public), answer inline: redirecting to
388
+ // it again would loop with a longer callbackUrl each time (QAPert 74651).
389
+ const named = check === 'revocation' && unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
390
+ if (check === 'revocation' && (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, ''))) {
391
+ const href = callback.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
392
+ return new NextResponse(
393
+ '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Temporarily unavailable</title>' +
394
+ '<main role="status" style="font-family:system-ui,sans-serif;max-width:28rem;margin:20vh auto;padding:0 1rem;text-align:center">' +
395
+ '<h1 style="font-size:1.25rem">Temporarily unavailable</h1>' +
396
+ '<p>We could not check your session just now. You are still signed in.</p>' +
397
+ '<p><a href="' + href + '">Try again</a></p></main>',
398
+ { status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' } },
399
+ );
400
+ }
401
+ const target = new URL(named || '/service-unavailable', request.url);
363
402
  if (callback !== '/') target.searchParams.set('callbackUrl', callback);
364
403
  return NextResponse.redirect(target);
365
404
  }
@@ -438,6 +477,14 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
438
477
  return { type: 'service_error', reason: 'viability_check_failed' };
439
478
  }
440
479
 
480
+ // --- PAY-2339: the revocation backend could not answer and this app HOLDS (the default) ---
481
+ // Not a verdict on the session: no cookie is touched, nothing protected is served, and the answer says why in a
482
+ // fixed machine-readable form. The login page stays reachable.
483
+ if (sessionStatus.unavailable) {
484
+ if (onLoginPage) return { type: 'allow' };
485
+ return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
486
+ }
487
+
441
488
  // --- Session force-invalidated (admin action, password change, etc.) ---
442
489
  if (sessionStatus.forceInvalid) {
443
490
  if (onLoginPage) return { type: 'allow' };
@@ -557,8 +604,9 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
557
604
  const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
558
605
  const bypassPaths = options.bypassPaths || [];
559
606
  const rbacExemptPaths = options.rbacExemptPaths || [];
560
- // PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
561
- const revocationFailMode = options.revocationFailMode || 'closed';
607
+ // PAY-2339: the default is 'hold' (Jon 2026-10-13, option C): an unknown revocation answer does not sign the member
608
+ // out. PAY-1882 step 4 had 'closed' (Jon's PAY-1880 option A, BAPert 66316); it is now opt-in by name.
609
+ const revocationFailMode = options.revocationFailMode || 'hold';
562
610
  // PAY-2302 cut 1c: strictly `=== true`, so a truthy string in a config file cannot switch revocation off.
563
611
  const noRevocationSource = options.noRevocationSource === true;
564
612
  if (noRevocationSource) {
@@ -693,6 +741,7 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
693
741
  refreshEndpoint,
694
742
  rbacExemptPaths,
695
743
  reset: resolveReset(),
744
+ unavailablePage: options.unavailablePage,
696
745
  onRefreshSuccess: options.onRefreshSuccess,
697
746
  onRefreshFailure: options.onRefreshFailure,
698
747
  });
@@ -711,26 +760,44 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
711
760
  * 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
712
761
  * absent (idealresume.online's older boolean-only route).
713
762
  */
714
- export function decideForceInvalid(
763
+ export type RevocationOutcome = 'viable' | 'invalid' | 'hold';
764
+
765
+ /**
766
+ * PAY-2339: the three-way form of the revocation decision. `decideForceInvalid` below is kept as its boolean view.
767
+ *
768
+ * 'invalid' - sign the member out (cookies cleared, login page)
769
+ * 'hold' - the backend could not answer and this app holds: no sign-out, answer "temporarily unavailable"
770
+ * 'viable' - carry on
771
+ */
772
+ export function decideRevocationOutcome(
715
773
  data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
716
- revocationFailMode: 'closed' | 'open',
774
+ revocationFailMode: 'hold' | 'closed' | 'open',
717
775
  noRevocationSource: boolean = false,
718
- ): boolean {
776
+ ): RevocationOutcome {
719
777
  const status = data.revocationStatus;
720
778
  if (typeof status === 'string') {
721
- if (status === 'revoked') return true;
722
- if (status === 'active') return false;
779
+ if (status === 'revoked') return 'invalid';
780
+ if (status === 'active') return 'viable';
723
781
  // PAY-2302 cut 1c: an app that declared it has no revocation source. ONLY the exact 'not_configured' string.
724
- if (status === 'not_configured' && noRevocationSource === true) return false;
725
- // S1 (NightHawk 66409): ANY other string - 'unknown', 'not_configured', a
726
- // typo like 'Revoked', 'error', or '' - is treated as 'unknown' (fail mode
727
- // applies), NEVER as 'active'. Returning false here would let the first
728
- // producer that emits an unexpected state silently switch revocation OFF -
729
- // the exact PAY-1880 fail-open class. Only an explicit 'active' is viable.
730
- return revocationFailMode === 'closed';
782
+ if (status === 'not_configured' && noRevocationSource === true) return 'viable';
783
+ // PAY-2339: 'not_configured' is a configuration fault, not an outage. Under the default it stays signed out
784
+ // exactly as before; only 'open' relaxes it (a Vibe-less app).
785
+ if (status === 'not_configured') return revocationFailMode === 'open' ? 'viable' : 'invalid';
786
+ // S1 (NightHawk 66409): ANY other string - 'unknown', a typo like 'Revoked', 'error', or '' - is treated as
787
+ // 'unknown' (the fail mode applies), NEVER as 'active'. Only an explicit 'active' is viable.
788
+ if (revocationFailMode === 'open') return 'viable';
789
+ return revocationFailMode === 'closed' ? 'invalid' : 'hold';
731
790
  }
732
- // No revocationStatus: an older boolean producer. Absence of both stays false.
733
- return data.forceInvalid === true || data.revoked === true;
791
+ // No revocationStatus: an older boolean producer. Absence of both stays viable.
792
+ return data.forceInvalid === true || data.revoked === true ? 'invalid' : 'viable';
793
+ }
794
+
795
+ export function decideForceInvalid(
796
+ data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
797
+ revocationFailMode: 'hold' | 'closed' | 'open',
798
+ noRevocationSource: boolean = false,
799
+ ): boolean {
800
+ return decideRevocationOutcome(data, revocationFailMode, noRevocationSource) === 'invalid';
734
801
  }
735
802
 
736
803
  /**
@@ -750,7 +817,7 @@ async function checkViability(
750
817
  request: NextRequest,
751
818
  endpoint: string,
752
819
  log: MiddlewareLogger,
753
- revocationFailMode: 'closed' | 'open',
820
+ revocationFailMode: 'hold' | 'closed' | 'open',
754
821
  noRevocationSource: boolean = false
755
822
  ): Promise<{
756
823
  sessionStatus: SessionStatus;
@@ -829,7 +896,13 @@ async function checkViability(
829
896
  // revocations status, decide from it + failMode and ignore the folded
830
897
  // booleans for unknown/not_configured. The booleans are read ONLY when
831
898
  // revocationStatus is ABSENT (idealresume.online's older boolean route).
832
- sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode, noRevocationSource);
899
+ const outcome = decideRevocationOutcome(data, revocationFailMode, noRevocationSource);
900
+ sessionStatus.forceInvalid = outcome === 'invalid';
901
+ if (outcome === 'hold') {
902
+ sessionStatus.unavailable = true;
903
+ // PAY-2339 P11: one countable line per held request, naming the check. No session value, no address.
904
+ log.warn('revocation_unavailable', { check: 'revocation' });
905
+ }
833
906
  // PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
834
907
  // pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
835
908
  // arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
@@ -869,6 +942,8 @@ interface ExecuteOptions {
869
942
  rbacExemptPaths: string[];
870
943
  /** PAY-2218: set when the app mounted the session-reset route AND a signing secret exists. */
871
944
  reset?: ResetConfig;
945
+ /** PAY-2339: the app's named hold page; absent = the inline 503 HTML. */
946
+ unavailablePage?: string;
872
947
  onRefreshSuccess?: () => void;
873
948
  onRefreshFailure?: (status: number, isNetworkError: boolean) => void;
874
949
  }
@@ -894,7 +969,7 @@ async function executeDecision(
894
969
  : redirectTo(request, decision.location);
895
970
 
896
971
  case 'service_error':
897
- return serviceUnavailableResponse(request);
972
+ return serviceUnavailableResponse(request, decision.check ?? 'viability', opts.unavailablePage);
898
973
 
899
974
  case 'refresh_needed':
900
975
  // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import React, { Suspense } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+
6
+ /**
7
+ * PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
8
+ * check (or the viability read) could not answer. Mount it in one line in the consuming app:
9
+ *
10
+ * // app/service-unavailable/page.tsx
11
+ * export { default } from '@payez/next-mvp/pages/service-unavailable';
12
+ *
13
+ * It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
14
+ * consumer may replace the page (and the wording) with its own at the same route.
15
+ *
16
+ * `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
17
+ * a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
18
+ */
19
+ export function safeReturnPath(raw: string | null | undefined): string {
20
+ if (!raw || typeof raw !== 'string') return '/';
21
+ if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) return '/';
22
+ if (/[\u0000-\u001f]/.test(raw)) return '/';
23
+ return raw;
24
+ }
25
+
26
+ function ServiceUnavailableContent() {
27
+ const params = useSearchParams();
28
+ const back = safeReturnPath(params?.get('callbackUrl'));
29
+ return (
30
+ <div className="min-h-screen flex items-center justify-center p-4">
31
+ <div
32
+ role="status"
33
+ className="max-w-md w-full text-center rounded-xl p-8 shadow-lg border"
34
+ style={{
35
+ backgroundColor: 'var(--bg-card, #ffffff)',
36
+ borderColor: 'var(--border-default, #e5e7eb)',
37
+ }}
38
+ >
39
+ <h1 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary, #111827)' }}>
40
+ Temporarily unavailable
41
+ </h1>
42
+ <p className="mb-6" style={{ color: 'var(--text-secondary, #6b7280)' }}>
43
+ We could not check your session just now. You are still signed in. Please try again in a moment.
44
+ </p>
45
+ <a
46
+ href={back}
47
+ className="inline-block rounded-lg px-4 py-2 font-medium"
48
+ style={{ background: 'var(--color-primary, #10b981)', color: 'white' }}
49
+ >
50
+ Try again
51
+ </a>
52
+ </div>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export default function ServiceUnavailablePage() {
58
+ return (
59
+ <Suspense fallback={null}>
60
+ <ServiceUnavailableContent />
61
+ </Suspense>
62
+ );
63
+ }
@@ -14,6 +14,7 @@
14
14
  * @since auth-ready-v2
15
15
  */
16
16
 
17
+ import { revocationFields } from '../../auth/revocation-fields';
17
18
  import { NextRequest, NextResponse } from 'next/server';
18
19
  import { resolveSessionState } from '../../server/auth';
19
20
  import {
@@ -157,6 +158,7 @@ export async function GET(req: NextRequest) {
157
158
  needsRefresh: false,
158
159
  authenticated: false,
159
160
  revocationStatus: 'unknown' as RevocationStatus,
161
+ unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
160
162
  error: 'Session store unavailable',
161
163
  code: 'STORE_UNAVAILABLE'
162
164
  }, { status: 500 });
@@ -383,8 +385,7 @@ export async function GET(req: NextRequest) {
383
385
  // Session revocation (PAY-1882 step 4): the four-state status, plus the
384
386
  // boolean the middleware's existing force-invalid branch already reads.
385
387
  revocationStatus,
386
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
387
- revoked: revocationStatus === 'revoked',
388
+ ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
388
389
  // Clear names for middleware decision-making
389
390
  tenantRequiresTwoFactor,
390
391
  userHasCompletedTenantTwoFactorRequirements,
@@ -468,8 +469,7 @@ export async function GET(req: NextRequest) {
468
469
  reason: 'Token expired',
469
470
  // Session revocation (PAY-1882 step 4) - same value on every return.
470
471
  revocationStatus,
471
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
472
- revoked: revocationStatus === 'revoked',
472
+ ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
473
473
  // RBAC fields
474
474
  roles: session?.roles || [],
475
475
  clientId: resolveClientId(session?.idpClientId),
@@ -485,8 +485,7 @@ export async function GET(req: NextRequest) {
485
485
  sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
486
486
  // Session revocation (PAY-1882 step 4) - same value on every return.
487
487
  revocationStatus,
488
- forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
489
- revoked: revocationStatus === 'revoked',
488
+ ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
490
489
  // Clear names
491
490
  tenantRequiresTwoFactor,
492
491
  userHasCompletedTenantTwoFactorRequirements,