@payez/next-mvp 4.5.0 → 4.5.2

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.
@@ -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
+ }
@@ -197,8 +197,30 @@ function getSafeCallbackUrl(pathname: string, searchParams?: URLSearchParams): s
197
197
  return resolveCallbackDestination(pathname, searchParams);
198
198
  }
199
199
 
200
- /** Clear all session cookies on a response */
200
+ /**
201
+ * PAY-2136 Rule A: a speculative request (router prefetch) must NEVER mutate cookies.
202
+ * A prefetch of /account/security was answered 307 + Set-Cookie (session cookies expired), the browser
203
+ * applied it, and the user's next real request (e.g. Approve) was signed out. Detected by any of:
204
+ * `Next-Router-Prefetch: 1|2` (Next sets it on EVERY Link prefetch; 2 = runtime prefetch) or `Sec-Purpose`/`Purpose`
205
+ * containing "prefetch".
206
+ * NOT `RSC: 1` + `_rsc`: a real client-side navigation carries both too (Nextpert-Scout 70976), so that shape
207
+ * would swallow in-app navigations and stop the middleware refreshing/clearing for them.
208
+ */
209
+ export function isPrefetchRequest(request: NextRequest): boolean {
210
+ const h = request.headers;
211
+ const nrp = h.get('next-router-prefetch');
212
+ if (nrp === '1' || nrp === '2') return true;
213
+ if (/prefetch/i.test(h.get('sec-purpose') ?? '') || /prefetch/i.test(h.get('purpose') ?? '')) return true;
214
+ return false;
215
+ }
216
+
217
+ /**
218
+ * Clear all session cookies on a response. The ONE site that writes the expiry Set-Cookie for the middleware,
219
+ * so the prefetch guard lives here: on a prefetch the redirect is still returned (routing unchanged) but
220
+ * carries no Set-Cookie; the next real navigation reaches the same decision and clears then.
221
+ */
201
222
  function clearSessionCookies(response: NextResponse, request: NextRequest): void {
223
+ if (isPrefetchRequest(request)) return;
202
224
  const sessionCookie = getSessionCookieName();
203
225
  const secureCookie = getSecureSessionCookieName();
204
226
 
@@ -736,6 +758,9 @@ async function executeDecision(
736
758
  return NextResponse.redirect(new URL('/service-unavailable', request.url));
737
759
 
738
760
  case 'refresh_needed':
761
+ // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
762
+ // clear the session. Let it through; the real navigation refreshes.
763
+ if (isPrefetchRequest(request)) return NextResponse.next();
739
764
  return handleRefresh(request, safeCallback, opts);
740
765
  }
741
766
  }