@payez/next-mvp 4.5.1 → 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.
@@ -39,5 +39,10 @@ export type AuthAction = {
39
39
  * Main decision function - pure, testable, deterministic.
40
40
  * PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
41
41
  * redirect itself is kept, and the next real navigation reaches the same decision and clears then.
42
+ *
43
+ * @deprecated PAY-2180: this exported engine is NOT the one the middleware runs. `createMvpMiddleware` decides with
44
+ * its own private engine (middleware/create-middleware.ts), so calling this answers a question nothing enforces.
45
+ * A sweep found no code consumer in the portal, idealvibe, idealresume or cryptaply. It will be REMOVED in the next
46
+ * major version, together with its re-exports from the package root and `/edge`. Use `createMvpMiddleware`.
42
47
  */
43
48
  export declare function makeAuthDecision(context: AuthContext): AuthAction;
@@ -9,12 +9,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.makeAuthDecision = makeAuthDecision;
10
10
  const safe_callback_1 = require("./safe-callback");
11
11
  const token_log_id_1 = require("../lib/token-log-id");
12
+ /** PAY-2180: warn once per process, not per request. */
13
+ let deprecationWarned = false;
12
14
  /**
13
15
  * Main decision function - pure, testable, deterministic.
14
16
  * PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
15
17
  * redirect itself is kept, and the next real navigation reaches the same decision and clears then.
18
+ *
19
+ * @deprecated PAY-2180: this exported engine is NOT the one the middleware runs. `createMvpMiddleware` decides with
20
+ * its own private engine (middleware/create-middleware.ts), so calling this answers a question nothing enforces.
21
+ * A sweep found no code consumer in the portal, idealvibe, idealresume or cryptaply. It will be REMOVED in the next
22
+ * major version, together with its re-exports from the package root and `/edge`. Use `createMvpMiddleware`.
16
23
  */
17
24
  function makeAuthDecision(context) {
25
+ if (!deprecationWarned) {
26
+ deprecationWarned = true;
27
+ console.warn('[next-mvp] makeAuthDecision (auth/auth-decision) is deprecated (PAY-2180) and will be removed in the next major: ' +
28
+ 'createMvpMiddleware does not use it. Use createMvpMiddleware.');
29
+ }
18
30
  const action = decide(context);
19
31
  if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
20
32
  const { clearCookies: _dropped, ...rest } = action;
@@ -49,8 +61,10 @@ function decide(context) {
49
61
  return {
50
62
  type: 'redirect',
51
63
  location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen',
52
- reason: 'CircuitBreakerOpen',
53
- 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.)
54
68
  };
55
69
  }
56
70
  // No session pointer (JWT cookie) - need to login
@@ -1,6 +1,7 @@
1
1
  import { NextRequest } from 'next/server';
2
2
  import { assertAbsoluteIdpUrl } from './idp-url-guard';
3
- export { assertAbsoluteIdpUrl };
3
+ import { parseRetryAfterSeconds } from './retry-after';
4
+ export { assertAbsoluteIdpUrl, parseRetryAfterSeconds };
4
5
  /**
5
6
  * Centralized IDP fetch helper
6
7
  * - Injects Bearer from Redis session
@@ -12,5 +13,11 @@ export interface IdpFetchResult<T = any> {
12
13
  status: number;
13
14
  json: T | null;
14
15
  attemptedRefresh: boolean;
16
+ /**
17
+ * PAY-1608: the upstream Retry-After in whole seconds, when the IDP sent a usable one. Core sends it on every
18
+ * 2FA lock (MfaController.SecondFactorLocked: 429 + Retry-After 900; VerifyTotp: what is left of the lock).
19
+ * It used to be dropped here, so no BFF could state the real lock window. Absent otherwise.
20
+ */
21
+ retryAfterSeconds?: number;
15
22
  }
16
23
  export declare function idpFetchJSON<T = any>(req: NextRequest, targetUrl: string, init?: RequestInit): Promise<IdpFetchResult<T>>;
@@ -33,13 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.assertAbsoluteIdpUrl = void 0;
36
+ exports.parseRetryAfterSeconds = exports.assertAbsoluteIdpUrl = void 0;
37
37
  exports.idpFetchJSON = idpFetchJSON;
38
38
  const test_aware_get_token_1 = require("./test-aware-get-token");
39
39
  const session_store_1 = require("./session-store");
40
40
  const internal_api_1 = require("./internal-api");
41
41
  const idp_url_guard_1 = require("./idp-url-guard");
42
42
  Object.defineProperty(exports, "assertAbsoluteIdpUrl", { enumerable: true, get: function () { return idp_url_guard_1.assertAbsoluteIdpUrl; } });
43
+ const retry_after_1 = require("./retry-after");
44
+ Object.defineProperty(exports, "parseRetryAfterSeconds", { enumerable: true, get: function () { return retry_after_1.parseRetryAfterSeconds; } });
43
45
  function buildHeaders(req, bearer, extra) {
44
46
  const headers = {
45
47
  Accept: 'application/json',
@@ -177,5 +179,6 @@ async function idpFetchJSON(req, targetUrl, init = {}) {
177
179
  }
178
180
  catch { }
179
181
  }
180
- return { ok: res.ok, status: res.status, json: json, attemptedRefresh };
182
+ const retryAfterSeconds = (0, retry_after_1.parseRetryAfterSeconds)(res.headers?.get?.('Retry-After'));
183
+ return { ok: res.ok, status: res.status, json: json, attemptedRefresh, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
181
184
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * PAY-1608: parse an HTTP Retry-After header to whole seconds.
3
+ *
4
+ * Accepts delta-seconds ("900") and an HTTP date in the future. Answers undefined for absent, zero, negative,
5
+ * fractional, past or malformed values - a caller that gets undefined states no window rather than a guessed one.
6
+ *
7
+ * A dependency-free leaf on purpose: idp-fetch re-exports it, but a test can load it without the session/redis
8
+ * graph idp-fetch pulls in.
9
+ */
10
+ export declare function parseRetryAfterSeconds(value: string | null | undefined, nowMs?: number): number | undefined;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseRetryAfterSeconds = parseRetryAfterSeconds;
4
+ /**
5
+ * PAY-1608: parse an HTTP Retry-After header to whole seconds.
6
+ *
7
+ * Accepts delta-seconds ("900") and an HTTP date in the future. Answers undefined for absent, zero, negative,
8
+ * fractional, past or malformed values - a caller that gets undefined states no window rather than a guessed one.
9
+ *
10
+ * A dependency-free leaf on purpose: idp-fetch re-exports it, but a test can load it without the session/redis
11
+ * graph idp-fetch pulls in.
12
+ */
13
+ function parseRetryAfterSeconds(value, nowMs = Date.now()) {
14
+ if (typeof value !== 'string')
15
+ return undefined;
16
+ const v = value.trim();
17
+ if (/^\d+$/.test(v)) {
18
+ const n = parseInt(v, 10);
19
+ return n > 0 ? n : undefined;
20
+ }
21
+ // Only something that looks like an HTTP date (it has letters: "Sat, 10 Oct 2026 12:01:30 GMT") is parsed as
22
+ // one; "-5" and "1.5" must not reach Date.parse, which would read them as years.
23
+ if (!/[a-z]/i.test(v))
24
+ return undefined;
25
+ const at = Date.parse(v);
26
+ if (!Number.isFinite(at))
27
+ return undefined;
28
+ const secs = Math.ceil((at - nowMs) / 1000);
29
+ return secs > 0 ? secs : undefined;
30
+ }
@@ -56,10 +56,18 @@ export declare function stepUpRefusal(status: number, json: unknown): StepUpRefu
56
56
  */
57
57
  export declare function upstreamErrorTexts(json: unknown): string[];
58
58
  export declare function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode;
59
+ /**
60
+ * PAY-1608: the window as words - "15 minutes", "1 minute", "45 seconds", "2 hours". Rounded UP, so the user is never
61
+ * told a shorter wait than the IDP enforces. Undefined in, undefined out.
62
+ */
63
+ export declare function formatLockWindow(seconds: number | undefined): string | undefined;
64
+ /** PAY-1608: the LOCKED sentence with the IDP's real window, or the number-free one when none was sent. */
65
+ export declare function lockedText(retryAfterSeconds?: number): string;
59
66
  export declare function twoFactorVerifyFailure(result: {
60
67
  status: number;
61
68
  json: unknown;
62
69
  attemptedRefresh: boolean;
70
+ retryAfterSeconds?: number;
63
71
  }, method: TwoFactorVerifyMethod): NextResponse;
64
72
  /**
65
73
  * PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
@@ -4,6 +4,8 @@ exports.STEP_UP_REFUSAL_CODE = void 0;
4
4
  exports.stepUpRefusal = stepUpRefusal;
5
5
  exports.upstreamErrorTexts = upstreamErrorTexts;
6
6
  exports.classifyTwoFactorVerifyFailure = classifyTwoFactorVerifyFailure;
7
+ exports.formatLockWindow = formatLockWindow;
8
+ exports.lockedText = lockedText;
7
9
  exports.twoFactorVerifyFailure = twoFactorVerifyFailure;
8
10
  exports.classifyTwoFactorSendFailure = classifyTwoFactorSendFailure;
9
11
  exports.twoFactorSendCodeFailure = twoFactorSendCodeFailure;
@@ -33,7 +35,9 @@ const INVALID_CODE_TEXT = {
33
35
  backup_code: 'Invalid backup code. Please try again.',
34
36
  };
35
37
  const FIXED_TEXT = {
36
- LOCKED: 'Too many incorrect codes. Verification is locked for 15 minutes - wait before entering another code.',
38
+ // PAY-1608: NO NUMBER HERE. This is the sentence for a lock whose window the IDP did not send; lockedText()
39
+ // states the real window when Retry-After arrives. A hard-coded "15 minutes" was true only while every lock was 15.
40
+ LOCKED: 'Too many incorrect codes. Verification is locked for now - even a correct code is refused until the lock ends. Wait before entering another code.',
37
41
  RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
38
42
  SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
39
43
  VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
@@ -186,13 +190,41 @@ function classifyTwoFactorVerifyFailure(status, json) {
186
190
  return 'VERIFY_UNAVAILABLE';
187
191
  return 'VERIFY_FAILED';
188
192
  }
193
+ /**
194
+ * PAY-1608: the window as words - "15 minutes", "1 minute", "45 seconds", "2 hours". Rounded UP, so the user is never
195
+ * told a shorter wait than the IDP enforces. Undefined in, undefined out.
196
+ */
197
+ function formatLockWindow(seconds) {
198
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0)
199
+ return undefined;
200
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'}`;
201
+ if (seconds < 60)
202
+ return plural(Math.ceil(seconds), 'second');
203
+ const minutes = Math.ceil(seconds / 60);
204
+ if (minutes < 120)
205
+ return plural(minutes, 'minute');
206
+ return plural(Math.ceil(minutes / 60), 'hour');
207
+ }
208
+ /** PAY-1608: the LOCKED sentence with the IDP's real window, or the number-free one when none was sent. */
209
+ function lockedText(retryAfterSeconds) {
210
+ const window = formatLockWindow(retryAfterSeconds);
211
+ return window
212
+ ? `Too many incorrect codes. Verification is locked for ${window} - wait before entering another code.`
213
+ : FIXED_TEXT.LOCKED;
214
+ }
189
215
  function twoFactorVerifyFailure(result, method) {
190
216
  const refusal = stepUpRefusal(result.status, result.json);
191
217
  if (refusal)
192
218
  return stepUpResponse(refusal, result.attemptedRefresh);
193
219
  const code = classifyTwoFactorVerifyFailure(result.status, result.json);
194
- const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code];
195
- return server_1.NextResponse.json({ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } }, { status: result.status });
220
+ const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method]
221
+ : code === 'LOCKED' ? lockedText(result.retryAfterSeconds)
222
+ : FIXED_TEXT[code];
223
+ // PAY-1608: a lock or a throttle relays the IDP's window, as a number (for a client that words it itself) and as the
224
+ // standard header. Never on other failures, and never invented: absent upstream means absent here.
225
+ const wait = (code === 'LOCKED' || code === 'RATE_LIMITED') && formatLockWindow(result.retryAfterSeconds)
226
+ ? Math.ceil(result.retryAfterSeconds) : undefined;
227
+ return server_1.NextResponse.json({ success: false, error, code, ...(wait ? { retryAfterSeconds: wait } : {}), meta: { attemptedRefresh: result.attemptedRefresh } }, { status: result.status, ...(wait ? { headers: { 'Retry-After': String(wait) } } : {}) });
196
228
  }
197
229
  const SEND_LABEL = { sms: 'text message', email: 'email' };
198
230
  function classifyTwoFactorSendFailure(status, json) {
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.5.1",
3
+ "version": "4.5.3",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process';
31
31
  import { readFileSync } from 'node:fs';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { dirname, join } from 'node:path';
34
+ import { resolvePublishTag } from './publish-health-tag.mjs';
34
35
 
35
36
  const here = dirname(fileURLToPath(import.meta.url));
36
37
  const pkgPath = join(here, '..', 'package.json');
@@ -69,6 +70,14 @@ try {
69
70
  const name = local.name;
70
71
  const localVersion = local.version;
71
72
 
73
+ // PAY-2136: compare against the dist-tag being published (`npm publish --tag next` -> `next`), not always `latest`.
74
+ const tag = resolvePublishTag(process.argv.slice(2), process.env);
75
+ if (tag === null) {
76
+ console.error('[publish-health] INCONCLUSIVE: the requested dist-tag is not a plain tag name.');
77
+ process.exit(2);
78
+ }
79
+ const registrySpec = tag === 'latest' ? name : `${name}@${tag}`;
80
+
72
81
  // Auth is reported separately: a dead credential is the ROOT of 171900, and folding
73
82
  // it into "lagging" would hide which of the two things is actually broken.
74
83
  let authUser = null;
@@ -105,11 +114,11 @@ const REGISTRY_RETRY_MS = 2000;
105
114
  let registryVersion = null;
106
115
  for (let attempt = 1; attempt <= REGISTRY_ATTEMPTS; attempt++) {
107
116
  try {
108
- registryVersion = run(['view', name, 'version', '--prefer-online']);
117
+ registryVersion = run(['view', registrySpec, 'version', '--prefer-online']);
109
118
  } catch (err) {
110
119
  const msg = String(err.stderr || err.message || '');
111
120
  if (/E404|404 Not Found/.test(msg)) {
112
- console.error(`[publish-health] FAIL: ${name} has NEVER been published to the registry.`);
121
+ console.error(`[publish-health] FAIL: ${registrySpec} has NEVER been published to the registry.`);
113
122
  console.error(`[publish-health] local=${localVersion} registry=<absent>`);
114
123
  process.exit(1);
115
124
  }
@@ -135,7 +144,7 @@ const delta = compareVersions(localVersion, registryVersion);
135
144
 
136
145
  console.log(`[publish-health] package : ${name}`);
137
146
  console.log(`[publish-health] local : ${localVersion}`);
138
- console.log(`[publish-health] registry : ${registryVersion}`);
147
+ console.log(`[publish-health] registry : ${registryVersion} (dist-tag ${tag})`);
139
148
  console.log(`[publish-health] npm auth : ${authUser ? `logged in as ${authUser}` : 'NOT AUTHENTICATED (npm whoami failed)'}`);
140
149
 
141
150
  if (delta > 0) {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Which dist-tag should the publish-health check compare the local version against?
3
+ *
4
+ * PAY-2136 (BAPert 71043): `npm publish --tag next` runs this check as `postpublish`, and it compared local against
5
+ * the `latest` tag, so a CORRECT next-tag publish printed "registry LAGS local" and exited 1 - noise nobody should
6
+ * learn to ignore. Resolution order: `--tag <x>` / `--tag=<x>` argument, then npm_config_tag (npm sets it inside
7
+ * lifecycle scripts from `--tag`), then `latest`. A tag that is not a plain word is rejected (null), never guessed.
8
+ */
9
+ const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
+
11
+ export function resolvePublishTag(argv = [], env = {}) {
12
+ let raw;
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const a = argv[i];
15
+ if (a === '--tag') {
16
+ raw = argv[i + 1];
17
+ break;
18
+ }
19
+ if (a.startsWith('--tag=')) {
20
+ raw = a.slice('--tag='.length);
21
+ break;
22
+ }
23
+ }
24
+ if (raw === undefined) raw = env.npm_config_tag;
25
+ if (raw === undefined || raw === '') return 'latest';
26
+ return TAG.test(raw) ? raw : null;
27
+ }
@@ -36,12 +36,27 @@ export type AuthAction =
36
36
  | { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
37
37
  | { type: 'service_error'; reason: string };
38
38
 
39
+ /** PAY-2180: warn once per process, not per request. */
40
+ let deprecationWarned = false;
41
+
39
42
  /**
40
43
  * Main decision function - pure, testable, deterministic.
41
44
  * PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
42
45
  * redirect itself is kept, and the next real navigation reaches the same decision and clears then.
46
+ *
47
+ * @deprecated PAY-2180: this exported engine is NOT the one the middleware runs. `createMvpMiddleware` decides with
48
+ * its own private engine (middleware/create-middleware.ts), so calling this answers a question nothing enforces.
49
+ * A sweep found no code consumer in the portal, idealvibe, idealresume or cryptaply. It will be REMOVED in the next
50
+ * major version, together with its re-exports from the package root and `/edge`. Use `createMvpMiddleware`.
43
51
  */
44
52
  export function makeAuthDecision(context: AuthContext): AuthAction {
53
+ if (!deprecationWarned) {
54
+ deprecationWarned = true;
55
+ console.warn(
56
+ '[next-mvp] makeAuthDecision (auth/auth-decision) is deprecated (PAY-2180) and will be removed in the next major: ' +
57
+ 'createMvpMiddleware does not use it. Use createMvpMiddleware.'
58
+ );
59
+ }
45
60
  const action = decide(context);
46
61
  if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
47
62
  const { clearCookies: _dropped, ...rest } = action;
@@ -88,8 +103,10 @@ function decide(context: AuthContext): AuthAction {
88
103
  return {
89
104
  type: 'redirect',
90
105
  location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen',
91
- reason: 'CircuitBreakerOpen',
92
- 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.)
93
110
  };
94
111
  }
95
112
 
@@ -3,12 +3,13 @@ import { getTokenTestAware } from './test-aware-get-token';
3
3
  import { getSession } from './session-store';
4
4
  import { internalRefresh } from './internal-api';
5
5
  import { assertAbsoluteIdpUrl } from './idp-url-guard';
6
+ import { parseRetryAfterSeconds } from './retry-after';
6
7
 
7
8
  // Re-exported so `idp-fetch` remains the single obvious import for IDP calling
8
9
  // concerns; the implementation lives in a dependency-free leaf so the three
9
10
  // direct-fetch callers (auth/jwks.ts, auth/passkey-signin.ts x2) do not have to
10
11
  // pull this module's server-only chain in for one regex.
11
- export { assertAbsoluteIdpUrl };
12
+ export { assertAbsoluteIdpUrl, parseRetryAfterSeconds };
12
13
 
13
14
  /**
14
15
  * Centralized IDP fetch helper
@@ -21,6 +22,12 @@ export interface IdpFetchResult<T = any> {
21
22
  status: number;
22
23
  json: T | null;
23
24
  attemptedRefresh: boolean;
25
+ /**
26
+ * PAY-1608: the upstream Retry-After in whole seconds, when the IDP sent a usable one. Core sends it on every
27
+ * 2FA lock (MfaController.SecondFactorLocked: 429 + Retry-After 900; VerifyTotp: what is left of the lock).
28
+ * It used to be dropped here, so no BFF could state the real lock window. Absent otherwise.
29
+ */
30
+ retryAfterSeconds?: number;
24
31
  }
25
32
 
26
33
  function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
@@ -158,5 +165,6 @@ export async function idpFetchJSON<T = any>(
158
165
  } catch {}
159
166
  }
160
167
 
161
- return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh };
168
+ const retryAfterSeconds = parseRetryAfterSeconds(res.headers?.get?.('Retry-After'));
169
+ return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
162
170
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * PAY-1608: parse an HTTP Retry-After header to whole seconds.
3
+ *
4
+ * Accepts delta-seconds ("900") and an HTTP date in the future. Answers undefined for absent, zero, negative,
5
+ * fractional, past or malformed values - a caller that gets undefined states no window rather than a guessed one.
6
+ *
7
+ * A dependency-free leaf on purpose: idp-fetch re-exports it, but a test can load it without the session/redis
8
+ * graph idp-fetch pulls in.
9
+ */
10
+ export function parseRetryAfterSeconds(value: string | null | undefined, nowMs: number = Date.now()): number | undefined {
11
+ if (typeof value !== 'string') return undefined;
12
+ const v = value.trim();
13
+ if (/^\d+$/.test(v)) {
14
+ const n = parseInt(v, 10);
15
+ return n > 0 ? n : undefined;
16
+ }
17
+ // Only something that looks like an HTTP date (it has letters: "Sat, 10 Oct 2026 12:01:30 GMT") is parsed as
18
+ // one; "-5" and "1.5" must not reach Date.parse, which would read them as years.
19
+ if (!/[a-z]/i.test(v)) return undefined;
20
+ const at = Date.parse(v);
21
+ if (!Number.isFinite(at)) return undefined;
22
+ const secs = Math.ceil((at - nowMs) / 1000);
23
+ return secs > 0 ? secs : undefined;
24
+ }
@@ -1,248 +1,277 @@
1
- /**
2
- * PAY-1443: the ONE way a 2FA verify handler answers an IDP rejection.
3
- *
4
- * Found live (portal.keelauth.com /account-auth/verify-code, 2026-09-17): a wrong
5
- * authenticator code rendered as the banner text `{ message = Invalid authentication code }`.
6
- * The handlers relayed `result.json?.message` verbatim, and the IDP's error text is not a
7
- * user-facing string:
8
- * - deployed IDP: the >=400 envelope sets Message = value.ToString() on the controller's
9
- * `new { message = ... }`, producing C#'s anonymous-object ToString `{ message = X }`
10
- * (or a DTO's TYPE NAME for a typed body);
11
- * - IDP master (e03a0e449): no ToString, but the lookup is case-sensitive, so the envelope
12
- * message is "An error occurred" and the real text sits only in error.details.message.
13
- *
14
- * So the upstream text is used ONLY TO CLASSIFY, never rendered. The caller gets:
15
- * - the upstream HTTP status, unchanged (verify-code's 401 branch probes session liveness);
16
- * - a stable `code` the UI can switch on;
17
- * - one of the fixed sentences below.
18
- */
19
- import { NextResponse } from 'next/server';
20
-
21
- export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
22
-
23
- export type TwoFactorVerifyFailureCode =
24
- | 'INVALID_CODE'
25
- | 'LOCKED'
26
- | 'RATE_LIMITED'
27
- | 'SESSION_REJECTED'
28
- | 'STEP_UP_FACTOR_NOT_ALLOWED'
29
- | 'VERIFY_UNAVAILABLE'
30
- | 'VERIFY_FAILED';
31
-
32
- const INVALID_CODE_TEXT: Record<TwoFactorVerifyMethod, string> = {
33
- totp: 'Invalid authentication code. Please try again.',
34
- sms: 'Invalid verification code. Please try again.',
35
- email: 'Invalid verification code. Please try again.',
36
- backup_code: 'Invalid backup code. Please try again.',
37
- };
38
-
39
- const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE' | 'STEP_UP_FACTOR_NOT_ALLOWED'>, string> = {
40
- LOCKED: 'Too many incorrect codes. Verification is locked for 15 minutes - wait before entering another code.',
41
- RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
42
- SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
43
- VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
44
- VERIFY_FAILED: 'We could not verify that code. Please try again.',
45
- };
46
-
47
- /**
48
- * MfaTotpService ErrorCode "locked": the bare code, or `code = locked` inside a deployed
49
- * ToString envelope.
50
- *
51
- * CAUGHT BEFORE SHIPPING (self-test, this file): an earlier draft wrote this with  word
52
- * boundaries around `code` and `locked`. The literal that landed on disk was not the two
53
- * characters backslash+b - it was a raw ASCII backspace byte (0x08), invisible in every editor
54
- * and terminal that rendered this file, INCLUDING Read/cat/grep in this session. A regex with a
55
- * literal backspace control character where a word-boundary assertion was intended matches
56
- * nothing real, so `IDP_LOCKED.test(...)` was silently false on every fixture - caught only
57
- * because the RED-first test stayed red after the earlier "fix" and a hex dump of the compiled
58
- * line showed 08 where 5c62 (backslash, b) should have been. Word boundaries are not
59
- * load-bearing for the shipped control cases (a bare code=locked substring is specific enough
60
- * here - the IdP field name is literally code), so this drops them rather than re-risk the same
61
- * invisible-byte trap.
62
- */
63
- const IDP_LOCKED = /^locked$|code\s*=\s*locked/i;
64
-
65
- /**
66
- * MfaTotpService ErrorCode "server_error" (PAY-1589: secret cannot be decrypted) or "service_unavailable" (330830): the replay store could not be read or written, so
67
- * the verify FAILED CLOSED. It is a fault on our side, not a wrong code. Matched on the CODE only, never on
68
- * the prose - "unavailable" appears in unrelated messages - in the same two forms as IDP_LOCKED.
69
- */
70
- const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:service_unavailable|server_error)\b/i;
71
-
72
- const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
73
-
74
- /**
75
- * PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
76
- * A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
77
- * IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
78
- * this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
79
- *
80
- * On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
81
- * object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
82
- * The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
83
- *
84
- * NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
85
- * maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
86
- * missing or malformed lists give EMPTY lists, never invented ones.
87
- */
88
- export const STEP_UP_REFUSAL_CODE = 'STEP_UP_FACTOR_NOT_ALLOWED';
89
- const STEP_UP_METHODS = ['totp', 'webauthn', 'backup_code'] as const;
90
- const STEP_UP_ACTIONS = ['use_authenticator', 'reset_password', 'enroll_authenticator'] as const;
91
- export type StepUpMethod = (typeof STEP_UP_METHODS)[number];
92
- export type StepUpAction = (typeof STEP_UP_ACTIONS)[number];
93
- export type StepUpNextStep = { action: 'use_authenticator'; methods: StepUpMethod[] } | { action: Exclude<StepUpAction, 'use_authenticator'> };
94
- export type StepUpRefusal = { allowed_methods: StepUpMethod[]; next_steps: StepUpNextStep[] };
95
-
96
- const STEP_UP_TEXT =
97
- 'This session was signed in with an emailed or texted code only, so another emailed or texted code cannot confirm it is you.';
98
-
99
- function allowedMethods(v: unknown): StepUpMethod[] {
100
- if (!Array.isArray(v)) return [];
101
- const out: StepUpMethod[] = [];
102
- for (const m of v) {
103
- if ((STEP_UP_METHODS as readonly unknown[]).includes(m) && !out.includes(m as StepUpMethod)) out.push(m as StepUpMethod);
104
- }
105
- return out;
106
- }
107
-
108
- /** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
109
- export function stepUpRefusal(status: number, json: unknown): StepUpRefusal | null {
110
- if (status !== 403 || !json || typeof json !== 'object') return null;
111
- const j = json as Record<string, unknown>;
112
- const err = j.error;
113
- let code: unknown;
114
- let src: Record<string, unknown> = j;
115
- if (err && typeof err === 'object') {
116
- const e = err as Record<string, unknown>;
117
- code = e.code;
118
- src = e.details && typeof e.details === 'object' ? (e.details as Record<string, unknown>) : {};
119
- } else {
120
- code = err;
121
- }
122
- if (code !== STEP_UP_REFUSAL_CODE) return null;
123
- const next_steps: StepUpNextStep[] = [];
124
- const seen = new Set<string>();
125
- for (const step of Array.isArray(src.next_steps) ? src.next_steps : []) {
126
- if (!step || typeof step !== 'object') continue;
127
- const action = (step as Record<string, unknown>).action;
128
- if (typeof action !== 'string' || !(STEP_UP_ACTIONS as readonly string[]).includes(action) || seen.has(action)) continue;
129
- seen.add(action);
130
- next_steps.push(
131
- action === 'use_authenticator'
132
- ? { action, methods: allowedMethods((step as Record<string, unknown>).methods) }
133
- : { action: action as Exclude<StepUpAction, 'use_authenticator'> },
134
- );
135
- }
136
- return { allowed_methods: allowedMethods(src.allowed_methods), next_steps };
137
- }
138
-
139
- function stepUpResponse(refusal: StepUpRefusal, attemptedRefresh: boolean): NextResponse {
140
- return NextResponse.json(
141
- { success: false, error: STEP_UP_TEXT, code: STEP_UP_REFUSAL_CODE, ...refusal, meta: { attemptedRefresh } },
142
- { status: 403 },
143
- );
144
- }
145
-
146
- /**
147
- * Every string the IDP might have put anywhere in the error envelope, unwrapped from
148
- * `{ message = X }` ToString form. Classification input only.
149
- */
150
- export function upstreamErrorTexts(json: unknown): string[] {
151
- const out: string[] = [];
152
- const add = (v: unknown) => {
153
- if (typeof v !== 'string' || !v) return;
154
- out.push(v);
155
- const m = v.match(/^\{\s*\w+\s*=\s*(.*?)\s*\}$/s);
156
- if (m) out.push(m[1]);
157
- };
158
- const j = json as Record<string, any> | null | undefined;
159
- if (!j || typeof j !== 'object') return out;
160
- add(j.message);
161
- add(j.code);
162
- add(j.error);
163
- if (j.error && typeof j.error === 'object') {
164
- add(j.error.message);
165
- add(j.error.code);
166
- const d = j.error.details;
167
- if (d && typeof d === 'object') { add(d.message); add(d.Message); add(d.code); add(d.errorCode); }
168
- }
169
- return out;
170
- }
171
-
172
- export function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode {
173
- // PAY-1611: checked FIRST - an exact code on a 403, so no prose rule below can claim it first.
174
- if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED';
175
- // PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
176
- // the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
177
- //
178
- // PAY-1574: THIS MUST COME BEFORE THE 429 TEST. PayEz-Core PR 366 answers a TOTP lockout as HTTP 429 carrying
179
- // code "locked". With `status === 429` first, that status returned RATE_LIMITED and the lock code the IdP had
180
- // just started sending was discarded unread - the tester saw a plain rate limit. A 429 with NO lock code is
181
- // still RATE_LIMITED, below.
182
- if (upstreamErrorTexts(json).some((t) => IDP_LOCKED.test(t))) return 'LOCKED';
183
- if (status === 429) return 'RATE_LIMITED';
184
- // PAY-1574: the verify failed closed on OUR side (replay store down). Not a wrong code, whatever the status.
185
- if (upstreamErrorTexts(json).some((t) => IDP_UNAVAILABLE.test(t))) return 'VERIFY_UNAVAILABLE';
186
- if (upstreamErrorTexts(json).some((t) => WRONG_CODE.test(t))) return 'INVALID_CODE';
187
- if (status === 401 || status === 403) return 'SESSION_REJECTED';
188
- if (status >= 500 || status === 0) return 'VERIFY_UNAVAILABLE';
189
- return 'VERIFY_FAILED';
190
- }
191
-
192
- export function twoFactorVerifyFailure(
193
- result: { status: number; json: unknown; attemptedRefresh: boolean },
194
- method: TwoFactorVerifyMethod,
195
- ): NextResponse {
196
- const refusal = stepUpRefusal(result.status, result.json);
197
- if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
198
- const code = classifyTwoFactorVerifyFailure(result.status, result.json);
199
- const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method] : FIXED_TEXT[code as keyof typeof FIXED_TEXT];
200
- return NextResponse.json(
201
- { success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
202
- { status: result.status },
203
- );
204
- }
205
-
206
- /**
207
- * PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
208
- * "wrong code" outcome, so it gets its own small classification, but the same rule applies:
209
- * upstream text/code never reach the response. The portal renders `data.message || data.error`,
210
- * so no `message` field is emitted on failure.
211
- */
212
- export type TwoFactorSendMethod = 'sms' | 'email';
213
-
214
- export type TwoFactorSendFailureCode =
215
- | 'RATE_LIMITED'
216
- | 'SESSION_REJECTED'
217
- | 'STEP_UP_FACTOR_NOT_ALLOWED'
218
- | 'SEND_UNAVAILABLE'
219
- | 'SEND_FAILED';
220
-
221
- const SEND_LABEL: Record<TwoFactorSendMethod, string> = { sms: 'text message', email: 'email' };
222
-
223
- export function classifyTwoFactorSendFailure(status: number, json?: unknown): TwoFactorSendFailureCode {
224
- if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED'; // PAY-1611, before the 401/403 branch
225
- if (status === 429) return 'RATE_LIMITED';
226
- if (status === 401 || status === 403) return 'SESSION_REJECTED';
227
- if (status >= 500 || status === 0) return 'SEND_UNAVAILABLE';
228
- return 'SEND_FAILED';
229
- }
230
-
231
- export function twoFactorSendCodeFailure(
232
- result: { status: number; attemptedRefresh: boolean; json?: unknown },
233
- method: TwoFactorSendMethod,
234
- ): NextResponse {
235
- const refusal = stepUpRefusal(result.status, result.json);
236
- if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
237
- const code = classifyTwoFactorSendFailure(result.status, result.json);
238
- const label = SEND_LABEL[method];
239
- const error =
240
- code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
241
- : code === 'SESSION_REJECTED' ? `We could not send a ${label} code for this session. Please try again.`
242
- : code === 'SEND_UNAVAILABLE' ? `We could not send a ${label} code right now. Please try again.`
243
- : `We could not send a ${label} code. Please try again.`;
244
- return NextResponse.json(
245
- { success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
246
- { status: result.status },
247
- );
248
- }
1
+ /**
2
+ * PAY-1443: the ONE way a 2FA verify handler answers an IDP rejection.
3
+ *
4
+ * Found live (portal.keelauth.com /account-auth/verify-code, 2026-09-17): a wrong
5
+ * authenticator code rendered as the banner text `{ message = Invalid authentication code }`.
6
+ * The handlers relayed `result.json?.message` verbatim, and the IDP's error text is not a
7
+ * user-facing string:
8
+ * - deployed IDP: the >=400 envelope sets Message = value.ToString() on the controller's
9
+ * `new { message = ... }`, producing C#'s anonymous-object ToString `{ message = X }`
10
+ * (or a DTO's TYPE NAME for a typed body);
11
+ * - IDP master (e03a0e449): no ToString, but the lookup is case-sensitive, so the envelope
12
+ * message is "An error occurred" and the real text sits only in error.details.message.
13
+ *
14
+ * So the upstream text is used ONLY TO CLASSIFY, never rendered. The caller gets:
15
+ * - the upstream HTTP status, unchanged (verify-code's 401 branch probes session liveness);
16
+ * - a stable `code` the UI can switch on;
17
+ * - one of the fixed sentences below.
18
+ */
19
+ import { NextResponse } from 'next/server';
20
+
21
+ export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
22
+
23
+ export type TwoFactorVerifyFailureCode =
24
+ | 'INVALID_CODE'
25
+ | 'LOCKED'
26
+ | 'RATE_LIMITED'
27
+ | 'SESSION_REJECTED'
28
+ | 'STEP_UP_FACTOR_NOT_ALLOWED'
29
+ | 'VERIFY_UNAVAILABLE'
30
+ | 'VERIFY_FAILED';
31
+
32
+ const INVALID_CODE_TEXT: Record<TwoFactorVerifyMethod, string> = {
33
+ totp: 'Invalid authentication code. Please try again.',
34
+ sms: 'Invalid verification code. Please try again.',
35
+ email: 'Invalid verification code. Please try again.',
36
+ backup_code: 'Invalid backup code. Please try again.',
37
+ };
38
+
39
+ const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE' | 'STEP_UP_FACTOR_NOT_ALLOWED'>, string> = {
40
+ // PAY-1608: NO NUMBER HERE. This is the sentence for a lock whose window the IDP did not send; lockedText()
41
+ // states the real window when Retry-After arrives. A hard-coded "15 minutes" was true only while every lock was 15.
42
+ LOCKED: 'Too many incorrect codes. Verification is locked for now - even a correct code is refused until the lock ends. Wait before entering another code.',
43
+ RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
44
+ SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
45
+ VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
46
+ VERIFY_FAILED: 'We could not verify that code. Please try again.',
47
+ };
48
+
49
+ /**
50
+ * MfaTotpService ErrorCode "locked": the bare code, or `code = locked` inside a deployed
51
+ * ToString envelope.
52
+ *
53
+ * CAUGHT BEFORE SHIPPING (self-test, this file): an earlier draft wrote this with  word
54
+ * boundaries around `code` and `locked`. The literal that landed on disk was not the two
55
+ * characters backslash+b - it was a raw ASCII backspace byte (0x08), invisible in every editor
56
+ * and terminal that rendered this file, INCLUDING Read/cat/grep in this session. A regex with a
57
+ * literal backspace control character where a word-boundary assertion was intended matches
58
+ * nothing real, so `IDP_LOCKED.test(...)` was silently false on every fixture - caught only
59
+ * because the RED-first test stayed red after the earlier "fix" and a hex dump of the compiled
60
+ * line showed 08 where 5c62 (backslash, b) should have been. Word boundaries are not
61
+ * load-bearing for the shipped control cases (a bare code=locked substring is specific enough
62
+ * here - the IdP field name is literally code), so this drops them rather than re-risk the same
63
+ * invisible-byte trap.
64
+ */
65
+ const IDP_LOCKED = /^locked$|code\s*=\s*locked/i;
66
+
67
+ /**
68
+ * MfaTotpService ErrorCode "server_error" (PAY-1589: secret cannot be decrypted) or "service_unavailable" (330830): the replay store could not be read or written, so
69
+ * the verify FAILED CLOSED. It is a fault on our side, not a wrong code. Matched on the CODE only, never on
70
+ * the prose - "unavailable" appears in unrelated messages - in the same two forms as IDP_LOCKED.
71
+ */
72
+ const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:service_unavailable|server_error)\b/i;
73
+
74
+ const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
75
+
76
+ /**
77
+ * PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
78
+ * A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
79
+ * IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
80
+ * this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
81
+ *
82
+ * On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
83
+ * object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
84
+ * The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
85
+ *
86
+ * NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
87
+ * maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
88
+ * missing or malformed lists give EMPTY lists, never invented ones.
89
+ */
90
+ export const STEP_UP_REFUSAL_CODE = 'STEP_UP_FACTOR_NOT_ALLOWED';
91
+ const STEP_UP_METHODS = ['totp', 'webauthn', 'backup_code'] as const;
92
+ const STEP_UP_ACTIONS = ['use_authenticator', 'reset_password', 'enroll_authenticator'] as const;
93
+ export type StepUpMethod = (typeof STEP_UP_METHODS)[number];
94
+ export type StepUpAction = (typeof STEP_UP_ACTIONS)[number];
95
+ export type StepUpNextStep = { action: 'use_authenticator'; methods: StepUpMethod[] } | { action: Exclude<StepUpAction, 'use_authenticator'> };
96
+ export type StepUpRefusal = { allowed_methods: StepUpMethod[]; next_steps: StepUpNextStep[] };
97
+
98
+ const STEP_UP_TEXT =
99
+ 'This session was signed in with an emailed or texted code only, so another emailed or texted code cannot confirm it is you.';
100
+
101
+ function allowedMethods(v: unknown): StepUpMethod[] {
102
+ if (!Array.isArray(v)) return [];
103
+ const out: StepUpMethod[] = [];
104
+ for (const m of v) {
105
+ if ((STEP_UP_METHODS as readonly unknown[]).includes(m) && !out.includes(m as StepUpMethod)) out.push(m as StepUpMethod);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
111
+ export function stepUpRefusal(status: number, json: unknown): StepUpRefusal | null {
112
+ if (status !== 403 || !json || typeof json !== 'object') return null;
113
+ const j = json as Record<string, unknown>;
114
+ const err = j.error;
115
+ let code: unknown;
116
+ let src: Record<string, unknown> = j;
117
+ if (err && typeof err === 'object') {
118
+ const e = err as Record<string, unknown>;
119
+ code = e.code;
120
+ src = e.details && typeof e.details === 'object' ? (e.details as Record<string, unknown>) : {};
121
+ } else {
122
+ code = err;
123
+ }
124
+ if (code !== STEP_UP_REFUSAL_CODE) return null;
125
+ const next_steps: StepUpNextStep[] = [];
126
+ const seen = new Set<string>();
127
+ for (const step of Array.isArray(src.next_steps) ? src.next_steps : []) {
128
+ if (!step || typeof step !== 'object') continue;
129
+ const action = (step as Record<string, unknown>).action;
130
+ if (typeof action !== 'string' || !(STEP_UP_ACTIONS as readonly string[]).includes(action) || seen.has(action)) continue;
131
+ seen.add(action);
132
+ next_steps.push(
133
+ action === 'use_authenticator'
134
+ ? { action, methods: allowedMethods((step as Record<string, unknown>).methods) }
135
+ : { action: action as Exclude<StepUpAction, 'use_authenticator'> },
136
+ );
137
+ }
138
+ return { allowed_methods: allowedMethods(src.allowed_methods), next_steps };
139
+ }
140
+
141
+ function stepUpResponse(refusal: StepUpRefusal, attemptedRefresh: boolean): NextResponse {
142
+ return NextResponse.json(
143
+ { success: false, error: STEP_UP_TEXT, code: STEP_UP_REFUSAL_CODE, ...refusal, meta: { attemptedRefresh } },
144
+ { status: 403 },
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Every string the IDP might have put anywhere in the error envelope, unwrapped from
150
+ * `{ message = X }` ToString form. Classification input only.
151
+ */
152
+ export function upstreamErrorTexts(json: unknown): string[] {
153
+ const out: string[] = [];
154
+ const add = (v: unknown) => {
155
+ if (typeof v !== 'string' || !v) return;
156
+ out.push(v);
157
+ const m = v.match(/^\{\s*\w+\s*=\s*(.*?)\s*\}$/s);
158
+ if (m) out.push(m[1]);
159
+ };
160
+ const j = json as Record<string, any> | null | undefined;
161
+ if (!j || typeof j !== 'object') return out;
162
+ add(j.message);
163
+ add(j.code);
164
+ add(j.error);
165
+ if (j.error && typeof j.error === 'object') {
166
+ add(j.error.message);
167
+ add(j.error.code);
168
+ const d = j.error.details;
169
+ if (d && typeof d === 'object') { add(d.message); add(d.Message); add(d.code); add(d.errorCode); }
170
+ }
171
+ return out;
172
+ }
173
+
174
+ export function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode {
175
+ // PAY-1611: checked FIRST - an exact code on a 403, so no prose rule below can claim it first.
176
+ if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED';
177
+ // PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
178
+ // the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
179
+ //
180
+ // PAY-1574: THIS MUST COME BEFORE THE 429 TEST. PayEz-Core PR 366 answers a TOTP lockout as HTTP 429 carrying
181
+ // code "locked". With `status === 429` first, that status returned RATE_LIMITED and the lock code the IdP had
182
+ // just started sending was discarded unread - the tester saw a plain rate limit. A 429 with NO lock code is
183
+ // still RATE_LIMITED, below.
184
+ if (upstreamErrorTexts(json).some((t) => IDP_LOCKED.test(t))) return 'LOCKED';
185
+ if (status === 429) return 'RATE_LIMITED';
186
+ // PAY-1574: the verify failed closed on OUR side (replay store down). Not a wrong code, whatever the status.
187
+ if (upstreamErrorTexts(json).some((t) => IDP_UNAVAILABLE.test(t))) return 'VERIFY_UNAVAILABLE';
188
+ if (upstreamErrorTexts(json).some((t) => WRONG_CODE.test(t))) return 'INVALID_CODE';
189
+ if (status === 401 || status === 403) return 'SESSION_REJECTED';
190
+ if (status >= 500 || status === 0) return 'VERIFY_UNAVAILABLE';
191
+ return 'VERIFY_FAILED';
192
+ }
193
+
194
+ /**
195
+ * PAY-1608: the window as words - "15 minutes", "1 minute", "45 seconds", "2 hours". Rounded UP, so the user is never
196
+ * told a shorter wait than the IDP enforces. Undefined in, undefined out.
197
+ */
198
+ export function formatLockWindow(seconds: number | undefined): string | undefined {
199
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return undefined;
200
+ const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'}`;
201
+ if (seconds < 60) return plural(Math.ceil(seconds), 'second');
202
+ const minutes = Math.ceil(seconds / 60);
203
+ if (minutes < 120) return plural(minutes, 'minute');
204
+ return plural(Math.ceil(minutes / 60), 'hour');
205
+ }
206
+
207
+ /** PAY-1608: the LOCKED sentence with the IDP's real window, or the number-free one when none was sent. */
208
+ export function lockedText(retryAfterSeconds?: number): string {
209
+ const window = formatLockWindow(retryAfterSeconds);
210
+ return window
211
+ ? `Too many incorrect codes. Verification is locked for ${window} - wait before entering another code.`
212
+ : FIXED_TEXT.LOCKED;
213
+ }
214
+
215
+ export function twoFactorVerifyFailure(
216
+ result: { status: number; json: unknown; attemptedRefresh: boolean; retryAfterSeconds?: number },
217
+ method: TwoFactorVerifyMethod,
218
+ ): NextResponse {
219
+ const refusal = stepUpRefusal(result.status, result.json);
220
+ if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
221
+ const code = classifyTwoFactorVerifyFailure(result.status, result.json);
222
+ const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method]
223
+ : code === 'LOCKED' ? lockedText(result.retryAfterSeconds)
224
+ : FIXED_TEXT[code as keyof typeof FIXED_TEXT];
225
+ // PAY-1608: a lock or a throttle relays the IDP's window, as a number (for a client that words it itself) and as the
226
+ // standard header. Never on other failures, and never invented: absent upstream means absent here.
227
+ const wait = (code === 'LOCKED' || code === 'RATE_LIMITED') && formatLockWindow(result.retryAfterSeconds)
228
+ ? Math.ceil(result.retryAfterSeconds as number) : undefined;
229
+ return NextResponse.json(
230
+ { success: false, error, code, ...(wait ? { retryAfterSeconds: wait } : {}), meta: { attemptedRefresh: result.attemptedRefresh } },
231
+ { status: result.status, ...(wait ? { headers: { 'Retry-After': String(wait) } } : {}) },
232
+ );
233
+ }
234
+
235
+ /**
236
+ * PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
237
+ * "wrong code" outcome, so it gets its own small classification, but the same rule applies:
238
+ * upstream text/code never reach the response. The portal renders `data.message || data.error`,
239
+ * so no `message` field is emitted on failure.
240
+ */
241
+ export type TwoFactorSendMethod = 'sms' | 'email';
242
+
243
+ export type TwoFactorSendFailureCode =
244
+ | 'RATE_LIMITED'
245
+ | 'SESSION_REJECTED'
246
+ | 'STEP_UP_FACTOR_NOT_ALLOWED'
247
+ | 'SEND_UNAVAILABLE'
248
+ | 'SEND_FAILED';
249
+
250
+ const SEND_LABEL: Record<TwoFactorSendMethod, string> = { sms: 'text message', email: 'email' };
251
+
252
+ export function classifyTwoFactorSendFailure(status: number, json?: unknown): TwoFactorSendFailureCode {
253
+ if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED'; // PAY-1611, before the 401/403 branch
254
+ if (status === 429) return 'RATE_LIMITED';
255
+ if (status === 401 || status === 403) return 'SESSION_REJECTED';
256
+ if (status >= 500 || status === 0) return 'SEND_UNAVAILABLE';
257
+ return 'SEND_FAILED';
258
+ }
259
+
260
+ export function twoFactorSendCodeFailure(
261
+ result: { status: number; attemptedRefresh: boolean; json?: unknown },
262
+ method: TwoFactorSendMethod,
263
+ ): NextResponse {
264
+ const refusal = stepUpRefusal(result.status, result.json);
265
+ if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
266
+ const code = classifyTwoFactorSendFailure(result.status, result.json);
267
+ const label = SEND_LABEL[method];
268
+ const error =
269
+ code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
270
+ : code === 'SESSION_REJECTED' ? `We could not send a ${label} code for this session. Please try again.`
271
+ : code === 'SEND_UNAVAILABLE' ? `We could not send a ${label} code right now. Please try again.`
272
+ : `We could not send a ${label} code. Please try again.`;
273
+ return NextResponse.json(
274
+ { success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
275
+ { status: result.status },
276
+ );
277
+ }
@@ -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,
@@ -716,11 +751,13 @@ async function checkViability(
716
751
  log.error('Viability check failed', { status: response.status });
717
752
  sessionStatus.exists = null;
718
753
  sessionStatus.forceInvalid = null;
754
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
719
755
  }
720
756
  } catch (error) {
721
757
  log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
722
758
  sessionStatus.exists = null;
723
759
  sessionStatus.forceInvalid = null;
760
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
724
761
  }
725
762
 
726
763
  return { sessionStatus, sessionPointer };
@@ -755,7 +792,7 @@ async function executeDecision(
755
792
  return redirectTo(request, decision.location, decision.clearCookies);
756
793
 
757
794
  case 'service_error':
758
- return NextResponse.redirect(new URL('/service-unavailable', request.url));
795
+ return serviceUnavailableResponse(request);
759
796
 
760
797
  case 'refresh_needed':
761
798
  // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
@@ -843,12 +880,9 @@ async function handleRefresh(
843
880
  // Check if circuit breaker allows refresh attempt
844
881
  if (!cb.canAttemptRefresh()) {
845
882
  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
- );
883
+ // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
884
+ // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
885
+ return serviceUnavailableResponse(request);
852
886
  }
853
887
 
854
888
  try {