@payez/next-mvp 4.12.1 → 4.13.1
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.
- package/CHANGELOG.md +57 -0
- package/dist/api-handlers/account/profile.d.ts +2 -39
- package/dist/api-handlers/account/profile.js +5 -2
- package/dist/api-handlers/anon/preferences.d.ts +1 -14
- package/dist/api-handlers/anon/preferences.js +5 -5
- package/dist/api-handlers/auth/credential-login.js +28 -4
- package/dist/api-handlers/health.d.ts +5 -0
- package/dist/api-handlers/health.js +48 -0
- package/dist/api-handlers/mfa/totp-confirm.js +8 -4
- package/dist/api-handlers/mfa/totp-enroll.js +8 -4
- package/dist/api-handlers/session/refresh-viability.d.ts +3 -15
- package/dist/api-handlers/session/refresh-viability.js +5 -2
- package/dist/api-handlers/session/viability.d.ts +3 -8
- package/dist/api-handlers/session/viability.js +11 -4
- package/dist/auth/better-auth-exposure.d.ts +11 -0
- package/dist/auth/better-auth-exposure.js +73 -0
- package/dist/auth/better-auth.d.ts +6 -0
- package/dist/auth/better-auth.js +42 -3
- package/dist/auth/route-config.js +9 -0
- package/dist/auth/session-reset-link.d.ts +32 -0
- package/dist/auth/session-reset-link.js +45 -1
- package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
- package/dist/components/auth/CredentialSignInForm.js +13 -4
- package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
- package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
- package/dist/components/mfa/QrCode.d.ts +6 -0
- package/dist/components/mfa/QrCode.js +14 -0
- package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
- package/dist/components/mfa/TotpEnrollPanel.js +117 -0
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/credential-sign-in-client.d.ts +26 -1
- package/dist/lib/credential-sign-in-client.js +32 -6
- package/dist/lib/idp-client-config.js +4 -1
- package/dist/lib/login-error-message.d.ts +3 -0
- package/dist/lib/login-error-message.js +12 -1
- package/dist/lib/mfa-json-guard.d.ts +12 -0
- package/dist/lib/mfa-json-guard.js +19 -0
- package/dist/lib/nextauth-secret.js +3 -1
- package/dist/lib/no-store.d.ts +13 -0
- package/dist/lib/no-store.js +35 -0
- package/dist/lib/payez-client-secret.d.ts +14 -0
- package/dist/lib/payez-client-secret.js +41 -0
- package/dist/lib/qrcode.d.ts +22 -0
- package/dist/lib/qrcode.js +362 -0
- package/dist/lib/sign-in-failure.d.ts +35 -0
- package/dist/lib/sign-in-failure.js +85 -0
- package/dist/lib/twofa-rejection.d.ts +2 -0
- package/dist/lib/twofa-rejection.js +5 -2
- package/dist/middleware/create-middleware.d.ts +24 -0
- package/dist/middleware/create-middleware.js +56 -9
- package/dist/middleware/prefetch-guard-status.d.ts +21 -0
- package/dist/middleware/prefetch-guard-status.js +42 -0
- package/dist/pages/enroll/page.d.ts +7 -0
- package/dist/pages/enroll/page.js +15 -0
- package/dist/pages/login/page.js +1 -1
- package/dist/routes/auth/session-reset.d.ts +11 -1
- package/dist/routes/auth/session-reset.js +38 -13
- package/dist/routes/auth/session.d.ts +2 -9
- package/dist/routes/auth/session.js +5 -2
- package/dist/routes/auth/viability.d.ts +3 -54
- package/dist/routes/auth/viability.js +16 -4
- package/dist/routes/health.d.ts +1 -0
- package/dist/routes/health.js +16 -0
- package/dist/routes/mfa/methods.d.ts +2 -2
- package/dist/routes/mfa/methods.js +3 -1
- package/dist/routes/passkeys/list.d.ts +2 -10
- package/dist/routes/passkeys/list.js +4 -2
- package/dist/routes/session/refresh-viability.d.ts +2 -1
- package/dist/routes/session/refresh-viability.js +4 -2
- package/dist/utils/api.d.ts +33 -6
- package/dist/utils/api.js +35 -2
- package/package.json +21 -1
- package/src/api-handlers/account/profile.ts +5 -1
- package/src/api-handlers/anon/preferences.ts +5 -4
- package/src/api-handlers/auth/credential-login.ts +29 -4
- package/src/api-handlers/health.ts +53 -0
- package/src/api-handlers/mfa/totp-confirm.ts +7 -4
- package/src/api-handlers/mfa/totp-enroll.ts +7 -4
- package/src/api-handlers/session/refresh-viability.ts +5 -1
- package/src/api-handlers/session/viability.ts +12 -4
- package/src/auth/better-auth-exposure.ts +69 -0
- package/src/auth/better-auth.ts +42 -3
- package/src/auth/route-config.ts +9 -0
- package/src/auth/session-reset-link.ts +47 -0
- package/src/components/auth/CredentialSignInForm.tsx +21 -3
- package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
- package/src/components/mfa/QrCode.tsx +24 -0
- package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/credential-sign-in-client.ts +48 -8
- package/src/lib/idp-client-config.ts +5 -1
- package/src/lib/login-error-message.ts +13 -1
- package/src/lib/mfa-json-guard.ts +19 -0
- package/src/lib/nextauth-secret.ts +3 -1
- package/src/lib/no-store.ts +30 -0
- package/src/lib/payez-client-secret.ts +39 -0
- package/src/lib/qrcode.ts +348 -0
- package/src/lib/sign-in-failure.ts +88 -0
- package/src/lib/twofa-rejection.ts +7 -2
- package/src/middleware/create-middleware.ts +69 -9
- package/src/middleware/prefetch-guard-status.ts +56 -0
- package/src/pages/enroll/page.tsx +22 -0
- package/src/pages/login/page.tsx +2 -1
- package/src/routes/auth/session-reset.ts +46 -11
- package/src/routes/auth/session.ts +5 -1
- package/src/routes/auth/viability.ts +16 -4
- package/src/routes/health.ts +14 -0
- package/src/routes/mfa/methods.ts +3 -1
- package/src/routes/passkeys/list.ts +5 -1
- package/src/routes/session/refresh-viability.ts +5 -1
- package/src/utils/api.ts +50 -8
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qrcode.ts — minimal QR Code generator (byte mode, ECC level M), a
|
|
3
|
+
* TypeScript port of Nayuki's qrcodegen (MIT) trimmed to what TOTP
|
|
4
|
+
* otpauth:// URIs need. Renders to an SVG path string (no library, no
|
|
5
|
+
* canvas) — D3 MFA page generates the QR client-side per contract
|
|
6
|
+
* (fe-be-contract §D3.1: "no BE QR image needed").
|
|
7
|
+
*
|
|
8
|
+
* Cross-checked bit-exact against python `qrcode` (see D3 day-end report).
|
|
9
|
+
* PAY-2631: copied verbatim from VibeID-Customer-Portal src/lib/qrcode.ts so the generated app draws the QR itself:
|
|
10
|
+
* the TOTP secret is never sent to a third-party QR service.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Galois field tables for Reed–Solomon
|
|
14
|
+
const EXP = new Uint8Array(512);
|
|
15
|
+
const LOG = new Uint8Array(256);
|
|
16
|
+
(() => {
|
|
17
|
+
let x = 1;
|
|
18
|
+
for (let i = 0; i < 255; i++) {
|
|
19
|
+
EXP[i] = x;
|
|
20
|
+
LOG[x] = i;
|
|
21
|
+
x <<= 1;
|
|
22
|
+
if (x & 0x100) x ^= 0x11d;
|
|
23
|
+
}
|
|
24
|
+
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
function gfMul(a: number, b: number): number {
|
|
28
|
+
return a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ECC codewords per block + block count, level M, versions 1–40
|
|
32
|
+
// [eccPerBlock, numBlocks] indexed by version
|
|
33
|
+
const M_TABLE: Array<[number, number]> = [
|
|
34
|
+
[0, 0],
|
|
35
|
+
[10, 1], [16, 1], [26, 1], [18, 2], [24, 2], [16, 4], [18, 4], [22, 4], [22, 5], [26, 5], // 1-10
|
|
36
|
+
[26, 6], [24, 6], [28, 8], [28, 7], [24, 10], [26, 10], [26, 11], [28, 12], [28, 11], [30, 12], // 11-20
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function rawDataModules(version: number): number {
|
|
40
|
+
// Nayuki qrcodegen getNumRawDataModules — exact.
|
|
41
|
+
let result = (16 * version + 128) * version + 64;
|
|
42
|
+
if (version >= 2) {
|
|
43
|
+
const numAlign = Math.floor(version / 7) + 2;
|
|
44
|
+
result -= (25 * numAlign - 10) * numAlign - 55;
|
|
45
|
+
if (version >= 7) result -= 36;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function dataCodewords(version: number, eccPerBlock: number, numBlocks: number): number {
|
|
51
|
+
return Math.floor(rawDataModules(version) / 8) - eccPerBlock * numBlocks;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface BitBuffer {
|
|
55
|
+
bits: number[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function appendBits(buf: BitBuffer, val: number, len: number) {
|
|
59
|
+
for (let i = len - 1; i >= 0; i--) buf.bits.push((val >>> i) & 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildSegments(text: string, buf: BitBuffer) {
|
|
63
|
+
// byte mode: 0100, count(8 for v1-9 / 16 for v10+), data
|
|
64
|
+
const bytes = new TextEncoder().encode(text);
|
|
65
|
+
appendBits(buf, 0x4, 4);
|
|
66
|
+
return bytes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function reedSolomon(data: Uint8Array, degree: number): Uint8Array {
|
|
70
|
+
// generator polynomial
|
|
71
|
+
const gen = new Uint8Array(degree);
|
|
72
|
+
gen[degree - 1] = 1;
|
|
73
|
+
let root = 1;
|
|
74
|
+
for (let i = 0; i < degree; i++) {
|
|
75
|
+
for (let j = 0; j < degree; j++) {
|
|
76
|
+
gen[j] = gfMul(gen[j], root);
|
|
77
|
+
if (j + 1 < degree) gen[j] ^= gen[j + 1];
|
|
78
|
+
}
|
|
79
|
+
root = gfMul(root, 0x02);
|
|
80
|
+
}
|
|
81
|
+
const res = new Uint8Array(degree);
|
|
82
|
+
for (const b of data) {
|
|
83
|
+
const factor = b ^ res[0];
|
|
84
|
+
res.copyWithin(0, 1);
|
|
85
|
+
res[degree - 1] = 0;
|
|
86
|
+
for (let i = 0; i < degree; i++) res[i] ^= gfMul(gen[i], factor);
|
|
87
|
+
}
|
|
88
|
+
return res;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const MASKS: Array<(r: number, c: number) => boolean> = [
|
|
92
|
+
(r, c) => (r + c) % 2 === 0,
|
|
93
|
+
(r) => r % 2 === 0,
|
|
94
|
+
(r, c) => c % 3 === 0,
|
|
95
|
+
(r, c) => (r + c) % 3 === 0,
|
|
96
|
+
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
|
97
|
+
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
|
98
|
+
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
99
|
+
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const FORMAT_ECC_M = 0; // format bits use (ecc,mask); M=00
|
|
103
|
+
const FORMAT_LOOKUP: number[] = (() => {
|
|
104
|
+
// Precomputed BCH(15,5) format strings for ecc=M(00), mask 0-7
|
|
105
|
+
const table: number[] = [];
|
|
106
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
107
|
+
const data = (0 << 3) | mask;
|
|
108
|
+
let rem = data;
|
|
109
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
110
|
+
table.push(((data << 10) | rem) ^ 0x5412);
|
|
111
|
+
}
|
|
112
|
+
return table;
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
export interface QrMatrix {
|
|
116
|
+
size: number;
|
|
117
|
+
modules: boolean[][]; // [row][col], true = dark
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Full pre-matrix pipeline: version pick → bitstream → ECC → interleave.
|
|
121
|
+
* Exported for the python cross-check harness (debugFinalBytes). */
|
|
122
|
+
function buildFinalData(text: string): { version: number; finalData: number[] } {
|
|
123
|
+
const bytes = new TextEncoder().encode(text);
|
|
124
|
+
// pick smallest version (1-10 supported) that fits: capacity M byte mode
|
|
125
|
+
let version = 0;
|
|
126
|
+
let dataCw = 0;
|
|
127
|
+
let eccPerBlock = 0;
|
|
128
|
+
let numBlocks = 0;
|
|
129
|
+
for (let v = 1; v <= 10; v++) {
|
|
130
|
+
[eccPerBlock, numBlocks] = M_TABLE[v];
|
|
131
|
+
dataCw = dataCodewords(v, eccPerBlock, numBlocks);
|
|
132
|
+
const countBits = v <= 9 ? 8 : 16;
|
|
133
|
+
const needed = Math.ceil((4 + countBits + bytes.length * 8) / 8);
|
|
134
|
+
if (needed <= dataCw) {
|
|
135
|
+
version = v;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (version === 0) throw new Error('text too long for QR v10-M');
|
|
140
|
+
|
|
141
|
+
// bit stream: mode, count, data, terminator, pad
|
|
142
|
+
const buf: BitBuffer = { bits: [] };
|
|
143
|
+
buildSegments(text, buf);
|
|
144
|
+
const countBits = version <= 9 ? 8 : 16;
|
|
145
|
+
appendBits(buf, bytes.length, countBits);
|
|
146
|
+
for (const b of bytes) appendBits(buf, b, 8);
|
|
147
|
+
const capacity = dataCw * 8;
|
|
148
|
+
appendBits(buf, 0, Math.min(4, capacity - buf.bits.length));
|
|
149
|
+
while (buf.bits.length % 8 !== 0) buf.bits.push(0);
|
|
150
|
+
const data = new Uint8Array(dataCw);
|
|
151
|
+
for (let i = 0; i < buf.bits.length / 8; i++) {
|
|
152
|
+
let b = 0;
|
|
153
|
+
for (let j = 0; j < 8; j++) b = (b << 1) | buf.bits[i * 8 + j];
|
|
154
|
+
data[i] = b;
|
|
155
|
+
}
|
|
156
|
+
for (let i = buf.bits.length / 8, pad = 0xec; i < dataCw; i++, pad ^= 0xec ^ 0x11) data[i] = pad;
|
|
157
|
+
|
|
158
|
+
// ECC + interleave
|
|
159
|
+
const blockLen = Math.floor(dataCw / numBlocks);
|
|
160
|
+
const longBlocks = dataCw % numBlocks;
|
|
161
|
+
const blocks: Uint8Array[] = [];
|
|
162
|
+
const eccs: Uint8Array[] = [];
|
|
163
|
+
let off = 0;
|
|
164
|
+
for (let i = 0; i < numBlocks; i++) {
|
|
165
|
+
const len = blockLen + (i >= numBlocks - longBlocks ? 1 : 0);
|
|
166
|
+
const blk = data.slice(off, off + len);
|
|
167
|
+
off += len;
|
|
168
|
+
blocks.push(blk);
|
|
169
|
+
eccs.push(reedSolomon(blk, eccPerBlock));
|
|
170
|
+
}
|
|
171
|
+
const finalData: number[] = [];
|
|
172
|
+
const maxLen = blockLen + 1;
|
|
173
|
+
for (let i = 0; i < maxLen; i++) {
|
|
174
|
+
for (let b = 0; b < numBlocks; b++) {
|
|
175
|
+
if (i < blocks[b].length) finalData.push(blocks[b][i]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (let i = 0; i < eccPerBlock; i++) {
|
|
179
|
+
for (let b = 0; b < numBlocks; b++) finalData.push(eccs[b][i]);
|
|
180
|
+
}
|
|
181
|
+
return { version, finalData };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function debugFinalBytes(text: string): { version: number; bytes: number[] } {
|
|
185
|
+
const { version, finalData } = buildFinalData(text);
|
|
186
|
+
return { version, bytes: finalData };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function encodeQr(text: string, maskOverride?: number): QrMatrix {
|
|
190
|
+
const { version, finalData } = buildFinalData(text);
|
|
191
|
+
const size = 17 + version * 4;
|
|
192
|
+
const mod: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
193
|
+
const isFunc: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
194
|
+
const setF = (r: number, c: number, v: boolean) => {
|
|
195
|
+
mod[r][c] = v;
|
|
196
|
+
isFunc[r][c] = true;
|
|
197
|
+
};
|
|
198
|
+
const finder = (r: number, c: number) => {
|
|
199
|
+
for (let dr = -1; dr <= 7; dr++)
|
|
200
|
+
for (let dc = -1; dc <= 7; dc++) {
|
|
201
|
+
const rr = r + dr;
|
|
202
|
+
const cc = c + dc;
|
|
203
|
+
if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue;
|
|
204
|
+
const inOuter = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
|
|
205
|
+
const v = inOuter && (dr === 0 || dr === 6 || dc === 0 || dc === 6 || (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
|
|
206
|
+
setF(rr, cc, v);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
finder(0, 0);
|
|
210
|
+
finder(0, size - 7);
|
|
211
|
+
finder(size - 7, 0);
|
|
212
|
+
// alignment pattern centers per spec table
|
|
213
|
+
const aligns =
|
|
214
|
+
version === 1 ? [] :
|
|
215
|
+
version === 2 ? [6, 18] :
|
|
216
|
+
version === 3 ? [6, 22] :
|
|
217
|
+
version === 4 ? [6, 26] :
|
|
218
|
+
version === 5 ? [6, 30] :
|
|
219
|
+
version === 6 ? [6, 34] :
|
|
220
|
+
version === 7 ? [6, 22, 38] :
|
|
221
|
+
version === 8 ? [6, 24, 42] :
|
|
222
|
+
version === 9 ? [6, 26, 46] :
|
|
223
|
+
[6, 28, 50]; // v10
|
|
224
|
+
for (const r of aligns)
|
|
225
|
+
for (const c of aligns) {
|
|
226
|
+
if ((r === 6 && c === 6) || (r === 6 && c === size - 7) || (r === size - 7 && c === 6)) continue;
|
|
227
|
+
for (let dr = -2; dr <= 2; dr++)
|
|
228
|
+
for (let dc = -2; dc <= 2; dc++) setF(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1);
|
|
229
|
+
}
|
|
230
|
+
// timing
|
|
231
|
+
for (let i = 8; i < size - 8; i++) {
|
|
232
|
+
if (!isFunc[6][i]) setF(6, i, i % 2 === 0);
|
|
233
|
+
if (!isFunc[i][6]) setF(i, 6, i % 2 === 0);
|
|
234
|
+
}
|
|
235
|
+
// dark module + format cells reserved (values written per mask).
|
|
236
|
+
// Canonical layout (verified against python-qrcode cell-for-cell):
|
|
237
|
+
// copy 1: (8,0..5)=bits14..9, (8,7)=bit8, (8,8)=bit7, (7,8)=bit6,
|
|
238
|
+
// (0..5,8)=bits0..5
|
|
239
|
+
// copy 2: (n-1..n-7, 8)=bits14..8, (8, n-8..n-1)=bits7..0
|
|
240
|
+
// dark module (n-8, 8) = 1
|
|
241
|
+
setF(size - 8, 8, true);
|
|
242
|
+
const formatCells: Array<[number, number]> = [];
|
|
243
|
+
for (let i = 0; i <= 5; i++) formatCells.push([8, i]);
|
|
244
|
+
formatCells.push([8, 7], [8, 8], [7, 8]);
|
|
245
|
+
for (let i = 0; i <= 5; i++) formatCells.push([i, 8]);
|
|
246
|
+
for (let i = 0; i <= 6; i++) formatCells.push([size - 1 - i, 8]);
|
|
247
|
+
for (let i = 0; i < 8; i++) formatCells.push([8, size - 8 + i]);
|
|
248
|
+
for (const [r, c] of formatCells) {
|
|
249
|
+
if (!(r === size - 8 && c === 8)) setF(r, c, false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// data placement (zigzag) — finalData is BYTES; place their BITS MSB-first
|
|
253
|
+
const placeData = (mask: number) => {
|
|
254
|
+
const m = mod.map((row) => row.slice());
|
|
255
|
+
let bit = 0;
|
|
256
|
+
let upward = true;
|
|
257
|
+
for (let c = size - 1; c >= 1; c -= 2) {
|
|
258
|
+
if (c === 6) c = 5;
|
|
259
|
+
for (let i = 0; i < size; i++) {
|
|
260
|
+
const r = upward ? size - 1 - i : i;
|
|
261
|
+
for (const cc of [c, c - 1]) {
|
|
262
|
+
if (isFunc[r][cc]) continue;
|
|
263
|
+
const byte = finalData[bit >>> 3] ?? 0;
|
|
264
|
+
let v = bit < finalData.length * 8 ? ((byte >>> (7 - (bit & 7))) & 1) === 1 : false;
|
|
265
|
+
if (MASKS[mask](r, cc)) v = !v;
|
|
266
|
+
m[r][cc] = v;
|
|
267
|
+
bit++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
upward = !upward;
|
|
271
|
+
}
|
|
272
|
+
// format bits — canonical placement, both copies
|
|
273
|
+
const fmt = FORMAT_LOOKUP[(FORMAT_ECC_M << 3) | mask];
|
|
274
|
+
const fbit = (i: number) => ((fmt >>> i) & 1) === 1;
|
|
275
|
+
for (let i = 0; i <= 5; i++) m[8][i] = fbit(14 - i);
|
|
276
|
+
m[8][7] = fbit(8);
|
|
277
|
+
m[8][8] = fbit(7);
|
|
278
|
+
m[7][8] = fbit(6);
|
|
279
|
+
for (let i = 0; i <= 5; i++) m[i][8] = fbit(i);
|
|
280
|
+
for (let i = 0; i <= 6; i++) m[size - 1 - i][8] = fbit(14 - i);
|
|
281
|
+
for (let i = 0; i < 8; i++) m[8][size - 8 + i] = fbit(7 - i);
|
|
282
|
+
return m;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// mask selection by penalty
|
|
286
|
+
const penalty = (g: boolean[][]): number => {
|
|
287
|
+
let score = 0;
|
|
288
|
+
const s = g.length;
|
|
289
|
+
// N1 runs
|
|
290
|
+
for (let i = 0; i < s; i++) {
|
|
291
|
+
for (const dir of [0, 1]) {
|
|
292
|
+
let run = 1;
|
|
293
|
+
for (let j = 1; j <= s; j++) {
|
|
294
|
+
const same = j < s && (dir === 0 ? g[i][j] === g[i][j - 1] : g[j][i] === g[j - 1][i]);
|
|
295
|
+
if (same) run++;
|
|
296
|
+
else {
|
|
297
|
+
if (run >= 5) score += 3 + (run - 5);
|
|
298
|
+
run = 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// N2 blocks
|
|
304
|
+
for (let r = 0; r < s - 1; r++)
|
|
305
|
+
for (let c = 0; c < s - 1; c++)
|
|
306
|
+
if (g[r][c] === g[r + 1][c] && g[r][c] === g[r][c + 1] && g[r][c] === g[r + 1][c + 1]) score += 3;
|
|
307
|
+
// N3 finder-like
|
|
308
|
+
const pat = [true, false, true, true, true, false, true, false, false, false, false];
|
|
309
|
+
const patR = [...pat].reverse();
|
|
310
|
+
for (let i = 0; i < s; i++) {
|
|
311
|
+
for (let j = 0; j + 11 <= s; j++) {
|
|
312
|
+
const row = g[i].slice(j, j + 11);
|
|
313
|
+
const rowS = row.every((v, k) => v === pat[k]) || row.every((v, k) => v === patR[k]);
|
|
314
|
+
if (rowS) score += 40;
|
|
315
|
+
const col = Array.from({ length: 11 }, (_, k) => g[j + k][i]);
|
|
316
|
+
const colS = col.every((v, k) => v === pat[k]) || col.every((v, k) => v === patR[k]);
|
|
317
|
+
if (colS) score += 40;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// N4 balance
|
|
321
|
+
const dark = g.flat().filter(Boolean).length;
|
|
322
|
+
const pct = (dark * 100) / (s * s);
|
|
323
|
+
score += Math.floor(Math.abs(pct - 50) / 5) * 10;
|
|
324
|
+
return score;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (maskOverride !== undefined) return { size, modules: placeData(maskOverride) };
|
|
328
|
+
|
|
329
|
+
let best: boolean[][] = [];
|
|
330
|
+
let bestScore = Infinity;
|
|
331
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
332
|
+
const g = placeData(mask);
|
|
333
|
+
const sc = penalty(g);
|
|
334
|
+
if (sc < bestScore) {
|
|
335
|
+
bestScore = sc;
|
|
336
|
+
best = g;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { size, modules: best };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Render to an SVG path (dark modules as 1x1 squares). */
|
|
343
|
+
export function qrToSvgPath(qr: QrMatrix): string {
|
|
344
|
+
const parts: string[] = [];
|
|
345
|
+
for (let r = 0; r < qr.size; r++)
|
|
346
|
+
for (let c = 0; c < qr.size; c++) if (qr.modules[r][c]) parts.push(`M${c},${r}h1v1h-1z`);
|
|
347
|
+
return parts.join('');
|
|
348
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2568 / PAY-2279 (BAPert 85415, 85419): what a failed credential sign-in SAYS, and the stable machine code it carries.
|
|
3
|
+
*
|
|
4
|
+
* ONE closed table. The sentence the page shows comes from here, keyed on a code; it never comes from a server message string
|
|
5
|
+
* (the IdP's wire text, a stack, an upstream detail). The code is derived from the response STATUS plus the IdP's error CODE
|
|
6
|
+
* (never its message text):
|
|
7
|
+
*
|
|
8
|
+
* invalid_credentials wrong password OR unknown account OR any other refusal. Identical on purpose: the sentence and the body
|
|
9
|
+
* must not separate "no such account" from "wrong password" (user enumeration).
|
|
10
|
+
* not_a_member the IdP error code NotAMemberOfThisApp, whatever the HTTP status. Decided by the IdP AFTER the password is
|
|
11
|
+
* proven (PayEz-Core InternalTokenController.IssueCorporateToken: password validation 401s first, membership
|
|
12
|
+
* comes after), so this sentence is shown only to someone who proved the credentials. The STATUS is not part
|
|
13
|
+
* of the key: the internal mint door answers 403, but the PUBLIC door relays the same refusal as HTTP 400
|
|
14
|
+
* (measured on dev-93, PR 1719 walk, Scout 85449). Keying on 403 made rule 2 unreachable.
|
|
15
|
+
* too_many_attempts 429 (any code), or an AccountLocked / UserLockedOut refusal. One sentence for both: the user is not told
|
|
16
|
+
* which. A lockout exists only for a real account, which is an accepted, argued signal (BAPert 85419).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type SignInFailureCode =
|
|
20
|
+
| 'invalid_credentials'
|
|
21
|
+
| 'not_a_member'
|
|
22
|
+
| 'too_many_attempts'
|
|
23
|
+
| 'invalid_second_factor'
|
|
24
|
+
| 'second_factor_unavailable'
|
|
25
|
+
| 'second_factor_rate_limited';
|
|
26
|
+
|
|
27
|
+
export const SIGN_IN_FAILURE_COPY: Record<SignInFailureCode, string> = {
|
|
28
|
+
invalid_credentials: 'Incorrect email or password.',
|
|
29
|
+
not_a_member: "Your account does not have access to this app. Ask the app's administrator to invite you.",
|
|
30
|
+
too_many_attempts: 'Too many sign-in attempts. Wait a few minutes and try again, or reset your password.',
|
|
31
|
+
// The code step (after the password was proven). BAPert 85795 item 2 wrote the first sentence; the second is the sentence this lane
|
|
32
|
+
// already showed for an IdP-side fault (twofa-rejection.ts), now chosen by code like the rest. Neither depends on IdP wording: the
|
|
33
|
+
// IdP answers a wrong TOTP code with the generic code INVALID_REQUEST + 'Invalid code', so the branch (status + lockout/fault codes),
|
|
34
|
+
// not the IdP code, decides.
|
|
35
|
+
invalid_second_factor: 'That code is not correct. Check the code and try again.',
|
|
36
|
+
// A plain IdP 429 on the code step (NOT a lockout, which stays too_many_attempts). BAPert 85813: 'your code is still valid' is dropped because
|
|
37
|
+
// nothing in this package proves a rate-limited attempt leaves the code unconsumed; add it back only on the IdP owner's confirmation.
|
|
38
|
+
second_factor_rate_limited: 'Too many attempts. Wait a few minutes and try again.',
|
|
39
|
+
second_factor_unavailable: 'We could not check your code just now - this is a problem on our side, not with your code. Try again shortly.',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* BAPert 85807: the lockout sentence states the window when the server sent one. N = retryAfter (seconds) rounded UP to whole minutes,
|
|
44
|
+
* clamped to an integer 1..60. Absent, unparseable or non-finite -> the plain table sentence. Only a NUMBER from the server is printed,
|
|
45
|
+
* never text. (1 is "1 minute", not "1 minutes".)
|
|
46
|
+
*/
|
|
47
|
+
export function tooManyAttemptsMessage(retryAfterSeconds: unknown, code: 'too_many_attempts' | 'second_factor_rate_limited' = 'too_many_attempts'): string {
|
|
48
|
+
const n = typeof retryAfterSeconds === 'number' ? retryAfterSeconds : typeof retryAfterSeconds === 'string' && /^\s*\d+(\.\d+)?\s*$/.test(retryAfterSeconds) ? Number(retryAfterSeconds) : NaN;
|
|
49
|
+
if (!Number.isFinite(n) || n <= 0) return SIGN_IN_FAILURE_COPY[code];
|
|
50
|
+
const minutes = Math.min(60, Math.max(1, Math.ceil(n / 60)));
|
|
51
|
+
const unit = minutes === 1 ? 'minute' : 'minutes';
|
|
52
|
+
return code === 'second_factor_rate_limited'
|
|
53
|
+
? `Too many attempts. Try again in ${minutes} ${unit}.`
|
|
54
|
+
: `Too many sign-in attempts. Try again in ${minutes} ${unit}, or reset your password.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True only for a code this table defines. A hostile or new string from the wire is not one. */
|
|
58
|
+
export function isSignInFailureCode(v: unknown): v is SignInFailureCode {
|
|
59
|
+
return typeof v === 'string' && Object.prototype.hasOwnProperty.call(SIGN_IN_FAILURE_COPY, v);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The sentence for a code, or null when the code is not in the table. */
|
|
63
|
+
export function signInFailureMessage(code: unknown): string | null {
|
|
64
|
+
return isSignInFailureCode(code) ? SIGN_IN_FAILURE_COPY[code] : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** BAPert 85432: an allowlisted policy refusal (SsoRequired / InvalidRequest) is the only server text a page may show: plain text only, at most this long. */
|
|
68
|
+
export const POLICY_DETAIL_MAX = 200;
|
|
69
|
+
|
|
70
|
+
/** The policy detail as shown: a string, trimmed, at most POLICY_DETAIL_MAX characters. Rendered as a React text node, never as HTML. */
|
|
71
|
+
export function capPolicyDetail(v: unknown): string | null {
|
|
72
|
+
if (typeof v !== "string") return null;
|
|
73
|
+
const t = v.trim();
|
|
74
|
+
return t ? t.slice(0, POLICY_DETAIL_MAX) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const norm = (c: unknown) => (typeof c === 'string' ? c.replace(/[^A-Za-z0-9]/g, '').toUpperCase() : '');
|
|
78
|
+
const NOT_A_MEMBER = 'NOTAMEMBEROFTHISAPP';
|
|
79
|
+
const LOCKED = new Set(['ACCOUNTLOCKED', 'USERLOCKEDOUT']);
|
|
80
|
+
|
|
81
|
+
/** Server side: the code for a refusal, from the HTTP status (429 only) and the IdP's error code. Called only for refusals. */
|
|
82
|
+
export function classifySignInFailure(status: number, idpCode: string | null | undefined): SignInFailureCode {
|
|
83
|
+
if (status === 429) return 'too_many_attempts';
|
|
84
|
+
const c = norm(idpCode);
|
|
85
|
+
if (LOCKED.has(c)) return 'too_many_attempts';
|
|
86
|
+
if (c === NOT_A_MEMBER) return 'not_a_member';
|
|
87
|
+
return 'invalid_credentials';
|
|
88
|
+
}
|
|
@@ -31,6 +31,8 @@ export interface TwofaRejection {
|
|
|
31
31
|
status: 400 | 429 | 502;
|
|
32
32
|
error: string;
|
|
33
33
|
retryAfter?: number;
|
|
34
|
+
/** True only for the plain IdP 429 (a lane rate limit), not for a `locked` lockout: the page words them differently. */
|
|
35
|
+
rateLimited?: true;
|
|
34
36
|
/**
|
|
35
37
|
* PAY-2284: true when the authenticator (or text) lane failed on OUR side and the emailed-code lane has not faulted. The sign-in
|
|
36
38
|
* card then shows a real "Email me a code instead" action. Before this, the copy said "use another sign-in method" and the screen
|
|
@@ -62,8 +64,11 @@ export function classifyTwofaRejection(lanes: TwofaLaneResult[], retryAfterHeade
|
|
|
62
64
|
const parsed = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
|
|
63
65
|
return {
|
|
64
66
|
status: 429,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// Not shown to users (the handler sends the table sentence for the code); no claim about the code's validity, which is unproven.
|
|
68
|
+
error: 'Too many attempts. Wait a few minutes and try again.',
|
|
69
|
+
// BAPert 85813: no invented number. The page prints retryAfter as minutes, so it is sent only when the IdP sent a usable Retry-After.
|
|
70
|
+
...(Number.isFinite(parsed) && parsed > 0 ? { retryAfter: parsed } : {}),
|
|
71
|
+
rateLimited: true,
|
|
67
72
|
};
|
|
68
73
|
}
|
|
69
74
|
// PAY-1589: an IdP-side fault that arrives as 400 + code (TOTP secret cannot be decrypted -> server_error;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* });
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { warnPrefetchGuardDeadOnce } from './prefetch-guard-status';
|
|
20
21
|
import { NextRequest, NextResponse } from 'next/server';
|
|
21
22
|
import { isUnauthenticatedRoute, should2FABypass } from '../auth/route-config';
|
|
22
23
|
import { resolveCallbackDestination, safeCallbackPath } from '../auth/safe-callback';
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
normaliseResetParams,
|
|
32
33
|
resetSecret,
|
|
33
34
|
signedResetHref,
|
|
35
|
+
resetSigningSecret,
|
|
34
36
|
} from '../auth/session-reset-link';
|
|
35
37
|
import { errForLog } from '../lib/log-safe';
|
|
36
38
|
|
|
@@ -140,6 +142,14 @@ export interface MvpMiddlewareOptions {
|
|
|
140
142
|
* an error is logged once. Default: off (the direct clear), because an unmounted route would 404 every sign-out.
|
|
141
143
|
*/
|
|
142
144
|
sessionResetRoute?: boolean | string;
|
|
145
|
+
/**
|
|
146
|
+
* PAY-2218 (BAPert 83102, Scout option 1): OFF by default. When the EDGE middleware cannot see BETTER_AUTH_SECRET / NEXTAUTH_SECRET (the IdP-supplied secret is
|
|
147
|
+
* published into process.env only in the Node process), the hand-off cannot be signed and the direct clear is kept. Turn this ON to sign it with a key derived from the
|
|
148
|
+
* session cookie instead (the env secret still wins where it IS visible). It must be turned on in BOTH places or the route refuses the link: here, and on the route
|
|
149
|
+
* with `createSessionResetHandler({ cookieKey: true })`. Accepted residual risk of ON: an attacker who controls a sibling subdomain can plant a cookie value they
|
|
150
|
+
* know and sign a link that clears THAT value (a forced logout of that value only). Every app that can see the env secret should leave this OFF.
|
|
151
|
+
*/
|
|
152
|
+
sessionResetCookieKey?: boolean;
|
|
143
153
|
/**
|
|
144
154
|
* PAY-1882 step 4 / PAY-2339 - how this app treats a producer that reports the session's
|
|
145
155
|
* revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
|
|
@@ -271,6 +281,34 @@ export function isPrefetchRequest(request: NextRequest): boolean {
|
|
|
271
281
|
return false;
|
|
272
282
|
}
|
|
273
283
|
|
|
284
|
+
/**
|
|
285
|
+
* PAY-2218 P2 (BAPert 86134, after NightHawk 86133 measured the sign-out on a real Next 15.5.9 server and a real Chromium).
|
|
286
|
+
* Next 15.x and 16.0-16.1 (and 16.2 without skipProxyUrlNormalize) strip the router-prefetch headers before middleware runs, so
|
|
287
|
+
* isPrefetchRequest() cannot see a router prefetch there. The browser's own fetch metadata is NOT stripped: a router prefetch is a
|
|
288
|
+
* fetch() (`Sec-Fetch-Mode: cors`, `Sec-Fetch-Dest: empty`), a real top-level navigation is `navigate` + `document`.
|
|
289
|
+
*
|
|
290
|
+
* THE TABLE (clearSessionCookies expires the cookies only when this returns false):
|
|
291
|
+
* Sec-Fetch-Mode present and not 'navigate' OR Sec-Fetch-Dest present and not 'document' -> true (do NOT clear)
|
|
292
|
+
* Sec-Fetch-Mode 'navigate' and/or Dest 'document', none different -> false (clear, as before)
|
|
293
|
+
* NO Sec-Fetch-* at all, and rsc / next-router-prefetch / next-router-state-tree / sec-purpose|purpose: prefetch present -> true (never clear)
|
|
294
|
+
* NO Sec-Fetch-* at all, none of those (curl, old clients, server-to-server) -> false (clear, as before)
|
|
295
|
+
* The redirect is still returned; the stale cookie is then cleared by the real navigation that follows.
|
|
296
|
+
* Fetch metadata is a forbidden header (page script cannot set it) and clearing is the safe direction, so a forged value can only delay a
|
|
297
|
+
* clear that the next navigation performs.
|
|
298
|
+
*/
|
|
299
|
+
export function isKnownNonNavigationRequest(request: NextRequest): boolean {
|
|
300
|
+
const h = request.headers;
|
|
301
|
+
const mode = h.get('sec-fetch-mode');
|
|
302
|
+
const dest = h.get('sec-fetch-dest');
|
|
303
|
+
if (mode !== null || dest !== null) {
|
|
304
|
+
if (mode !== null && mode.trim().toLowerCase() !== 'navigate') return true;
|
|
305
|
+
if (dest !== null && dest.trim().toLowerCase() !== 'document') return true;
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (isPrefetchRequest(request)) return true;
|
|
309
|
+
return h.has('rsc') || h.has('next-router-prefetch') || h.has('next-router-state-tree');
|
|
310
|
+
}
|
|
311
|
+
|
|
274
312
|
/**
|
|
275
313
|
* Clear all session cookies on a response. The ONE site that writes the expiry Set-Cookie for the middleware,
|
|
276
314
|
* so the prefetch guard lives here: on a prefetch the redirect is still returned (routing unchanged) but
|
|
@@ -278,6 +316,7 @@ export function isPrefetchRequest(request: NextRequest): boolean {
|
|
|
278
316
|
*/
|
|
279
317
|
function clearSessionCookies(response: NextResponse, request: NextRequest): void {
|
|
280
318
|
if (isPrefetchRequest(request)) return;
|
|
319
|
+
if (isKnownNonNavigationRequest(request)) return;
|
|
281
320
|
expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
|
|
282
321
|
}
|
|
283
322
|
|
|
@@ -297,7 +336,12 @@ function redirectTo(
|
|
|
297
336
|
/** PAY-2218: where a page's clearing redirect goes when the app has mounted the session-reset route. */
|
|
298
337
|
interface ResetConfig {
|
|
299
338
|
route: string;
|
|
300
|
-
secret
|
|
339
|
+
/** The env secret when the edge can see it. */
|
|
340
|
+
envSecret?: string;
|
|
341
|
+
/** PAY-2218 opt-in: when true and there is no env secret, a key derived from the session cookie signs the hand-off (resetSigningSecret). */
|
|
342
|
+
cookieKey?: boolean;
|
|
343
|
+
/** Called once per request that could not be signed at all (no usable secret; with cookieKey also no session cookie). */
|
|
344
|
+
onCannotSign?: () => void;
|
|
301
345
|
}
|
|
302
346
|
|
|
303
347
|
/**
|
|
@@ -318,7 +362,12 @@ async function clearingRedirect(request: NextRequest, location: string, reset?:
|
|
|
318
362
|
});
|
|
319
363
|
// Bound to THIS request's session cookie: the route clears only when its own request carries the same one.
|
|
320
364
|
const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
|
|
321
|
-
const
|
|
365
|
+
const secret = reset.cookieKey ? await resetSigningSecret(reset.envSecret, bound) : reset.envSecret;
|
|
366
|
+
if (!secret) {
|
|
367
|
+
reset.onCannotSign?.();
|
|
368
|
+
return redirectTo(request, location, true);
|
|
369
|
+
}
|
|
370
|
+
const response = NextResponse.redirect(new URL(await signedResetHref(reset.route, params, secret, bound), request.url));
|
|
322
371
|
response.headers.set('cache-control', 'no-store');
|
|
323
372
|
return response;
|
|
324
373
|
}
|
|
@@ -611,6 +660,8 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
611
660
|
const revocationFailMode = options.revocationFailMode || 'hold';
|
|
612
661
|
// PAY-2302 cut 1c: strictly `=== true`, so a truthy string in a config file cannot switch revocation off.
|
|
613
662
|
const noRevocationSource = options.noRevocationSource === true;
|
|
663
|
+
// PAY-2218 P1: log once if this Next version cannot show middleware a router prefetch. Log-only.
|
|
664
|
+
warnPrefetchGuardDeadOnce((m) => log.warn(m));
|
|
614
665
|
if (noRevocationSource) {
|
|
615
666
|
log.warn(
|
|
616
667
|
'[next-mvp] noRevocationSource is ON: sessions of this app CANNOT be revoked server-side (no vibe client / HMAC key). ' +
|
|
@@ -625,13 +676,22 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
625
676
|
let resetSecretMissingLogged = false;
|
|
626
677
|
const resolveReset = (): ResetConfig | undefined => {
|
|
627
678
|
if (!sessionResetRoute) return undefined;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
679
|
+
return {
|
|
680
|
+
route: sessionResetRoute,
|
|
681
|
+
envSecret: resetSecret(),
|
|
682
|
+
cookieKey: options.sessionResetCookieKey === true,
|
|
683
|
+
onCannotSign: () => {
|
|
684
|
+
if (resetSecretMissingLogged) return;
|
|
685
|
+
resetSecretMissingLogged = true;
|
|
686
|
+
// No values here: only the route name.
|
|
687
|
+
log.error(
|
|
688
|
+
options.sessionResetCookieKey === true
|
|
689
|
+
? 'sessionResetRoute is set but there is neither a BETTER_AUTH_SECRET/NEXTAUTH_SECRET visible to the middleware nor a session cookie to derive a signing key from; clearing in middleware instead'
|
|
690
|
+
: 'sessionResetRoute is set but no BETTER_AUTH_SECRET/NEXTAUTH_SECRET is available to sign the hand-off; clearing in middleware instead (set sessionResetCookieKey, and createSessionResetHandler({ cookieKey: true }) on the route, if the edge cannot see the env secret)',
|
|
691
|
+
{ sessionResetRoute }
|
|
692
|
+
);
|
|
693
|
+
},
|
|
694
|
+
};
|
|
635
695
|
};
|
|
636
696
|
|
|
637
697
|
return async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2218 P1 (BAPert 86123, reworded 86209): say, once, at middleware creation, that the HEADER-based router-prefetch guard cannot see router prefetches
|
|
3
|
+
* on this Next version. After P2 the session-cookie clear no longer depends on it (it keys on Sec-Fetch-Mode/Dest), so this is information, not a call to
|
|
4
|
+
* migrate. No behaviour change: this only logs.
|
|
5
|
+
*
|
|
6
|
+
* MEASURED (adapter.js read in each version's own node_modules): Next 15.5.9 and 16.1.5 delete the flight headers (rsc, next-router-prefetch, ...)
|
|
7
|
+
* from the request middleware sees UNCONDITIONALLY, and 15.5.9 also strips `_rsc` from the URL, so isPrefetchRequest() is false for every router
|
|
8
|
+
* prefetch and no config can change that. Next 16.2.x skips the strip when `skipProxyUrlNormalize: true`.
|
|
9
|
+
*
|
|
10
|
+
* NOT warned, on purpose: Next >= 16.2 (the flag is a build-time define that user middleware code cannot reliably read back, so a warning there
|
|
11
|
+
* would be a guess) and Next 14 (not measured). Unparseable or missing version: silent.
|
|
12
|
+
*/
|
|
13
|
+
export type PrefetchGuardStatus = { dead: boolean; reason: string };
|
|
14
|
+
|
|
15
|
+
export function prefetchGuardStatus(nextVersion: string | undefined | null): PrefetchGuardStatus {
|
|
16
|
+
const m = /^(\d+)\.(\d+)\./.exec(String(nextVersion ?? '').trim());
|
|
17
|
+
if (!m) return { dead: false, reason: 'unknown version' };
|
|
18
|
+
const major = Number(m[1]);
|
|
19
|
+
const minor = Number(m[2]);
|
|
20
|
+
if (major === 15 || (major === 16 && minor < 2)) {
|
|
21
|
+
return { dead: true, reason: 'strips the router-prefetch headers (rsc, next-router-prefetch) before middleware runs and has no opt-out' };
|
|
22
|
+
}
|
|
23
|
+
return { dead: false, reason: 'not measured as dead' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function detectNextVersion(): string | undefined {
|
|
27
|
+
try {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
|
+
return (require('next/package.json') as { version?: string }).version;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let warned = false;
|
|
36
|
+
|
|
37
|
+
/** Logs PAYEZ_PREFETCH_GUARD_DEAD once per process when the guard is dead on this Next version. Returns true when it logged. */
|
|
38
|
+
export function warnPrefetchGuardDeadOnce(
|
|
39
|
+
warn: (message: string, data?: unknown) => void,
|
|
40
|
+
nextVersion: string | undefined | null = detectNextVersion(),
|
|
41
|
+
): boolean {
|
|
42
|
+
if (warned) return false;
|
|
43
|
+
const s = prefetchGuardStatus(nextVersion);
|
|
44
|
+
if (!s.dead) return false;
|
|
45
|
+
warned = true;
|
|
46
|
+
warn(
|
|
47
|
+
`PAYEZ_PREFETCH_GUARD_DEAD next=${nextVersion}: this Next version ${s.reason}, so isPrefetchRequest() cannot see a router prefetch. ` +
|
|
48
|
+
"The session-cookie clear relies on the browser's Sec-Fetch-Mode/Dest instead (PAY-2218 P2); non-browser clients and browsers that send no Sec-Fetch headers are not covered.",
|
|
49
|
+
);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test seam: forget that the warning was logged. */
|
|
54
|
+
export function resetPrefetchGuardWarningForTests(): void {
|
|
55
|
+
warned = false;
|
|
56
|
+
}
|