@payez/next-mvp 4.5.5 → 4.5.7

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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * PAY-2218 PR 2: the session-reset route. Mount it at app/api/auth/session-reset/route.ts:
3
+ *
4
+ * export { GET } from '@payez/next-mvp/routes/auth/session-reset';
5
+ *
6
+ * and turn it on with createMvpMiddleware({ sessionResetRoute: true }). The middleware then sends a page's
7
+ * session-ending redirect (stale session, force-invalidated, pre-2FA timeout, refresh 401/403) here instead of
8
+ * clearing the cookies itself. See src/auth/session-reset-link.ts for the measurements behind each rule.
9
+ *
10
+ * - Router PREFETCH (the route sees `next-router-prefetch`, which Next strips before middleware): 204 + no-store,
11
+ * no redirect, no Set-Cookie. A redirect here would be cached by the 15.5.x router and reused by the click, so
12
+ * the real navigation would never come back to clear.
13
+ * - Real navigation with a valid signed hand-off: expire the session cookies and 307 to the login page.
14
+ * - Anything unsigned, tampered, expired, or bound to a DIFFERENT session than this request's cookie: 307 to the
15
+ * login page and touch NO cookie (nobody can link a signed-in user here to log them out; a second use is a no-op).
16
+ *
17
+ * The login URL is always rebuilt from allowlisted parts; the route never redirects to a URL it was given.
18
+ */
19
+ import { NextRequest, NextResponse } from 'next/server';
20
+ import { isPrefetchRequest } from '../../middleware/create-middleware';
21
+ import { expireSessionCookies } from '../../auth/session-cookie-clear';
22
+ import { getSessionCookieName, getSecureSessionCookieName } from '../../lib/app-slug';
23
+ import { boundSessionCookie, loginLocation, readResetRequest, resetSecret } from '../../auth/session-reset-link';
24
+
25
+ export const dynamic = 'force-dynamic';
26
+
27
+ export async function GET(request: NextRequest): Promise<NextResponse> {
28
+ if (isPrefetchRequest(request)) {
29
+ return new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
30
+ }
31
+ const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
32
+ const { params, signed } = await readResetRequest(request.nextUrl.searchParams, resetSecret(), bound);
33
+ const response = NextResponse.redirect(new URL(loginLocation(params), request.url), 307);
34
+ response.headers.set('cache-control', 'no-store');
35
+ if (signed) expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
36
+ return response;
37
+ }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { NextRequest, NextResponse } from 'next/server';
18
- import { getSession as getBetterAuthSession } from '../../server/auth';
18
+ import { resolveSessionState } from '../../server/auth';
19
19
  import {
20
20
  getSession as getRedisSession,
21
21
  getBetterAuthSession as getBetterAuthRedisSession,
@@ -142,9 +142,40 @@ function resolveClientId(sessionClientId: string | undefined): string | null {
142
142
  */
143
143
  export async function GET(req: NextRequest) {
144
144
  try {
145
- const baSession = await getBetterAuthSession(req);
145
+ // PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
146
+ // session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
147
+ // never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
148
+ const sessionState = await resolveSessionState(req);
146
149
 
147
- if (!baSession) {
150
+ if (sessionState.state === 'unknown') {
151
+ // The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
152
+ // Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
153
+ // path, a non-OK answer -> service_error, which never clears cookies.
154
+ console.warn('[VIABILITY] Session store unavailable - session state unknown');
155
+ return NextResponse.json({
156
+ viable: false,
157
+ needsRefresh: false,
158
+ authenticated: false,
159
+ revocationStatus: 'unknown' as RevocationStatus,
160
+ error: 'Session store unavailable',
161
+ code: 'STORE_UNAVAILABLE'
162
+ }, { status: 500 });
163
+ }
164
+
165
+ if (sessionState.state === 'missing') {
166
+ // A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
167
+ // stale_session arm (PAY-2216 / 4.5.4) clears it.
168
+ console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
169
+ return NextResponse.json({
170
+ viable: false,
171
+ needsRefresh: false,
172
+ authenticated: false,
173
+ sessionTokenId: tokenLogId(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
174
+ reason: 'Stale session - cookie exists but session not found in Redis'
175
+ });
176
+ }
177
+
178
+ if (sessionState.state === 'none') {
148
179
  return NextResponse.json({
149
180
  viable: false,
150
181
  needsRefresh: false,
@@ -153,6 +184,8 @@ export async function GET(req: NextRequest) {
153
184
  });
154
185
  }
155
186
 
187
+ const baSession = sessionState.session;
188
+
156
189
  const token = baSession as any;
157
190
  const sessionToken = baSession.session?.token;
158
191
 
@@ -221,17 +221,60 @@ function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
221
221
  * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
222
222
  *
223
223
  * Returns the session object or null if not authenticated per Redis.
224
+ *
225
+ * PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
226
+ * Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
227
+ * unreachable store call resolveSessionState directly.
224
228
  */
225
229
  export async function getSession(request?: Request): Promise<any> {
230
+ const state = await resolveSessionState(request);
231
+ return state.state === 'live' ? state.session : null;
232
+ }
233
+
234
+ /**
235
+ * PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
236
+ * non-live answers to `null`, and that collapse is the defect this type exists to undo.
237
+ *
238
+ * MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
239
+ * a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
240
+ * identical `200 No session found` for both. Clearing on that shape would log every live user out on an
241
+ * outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
242
+ *
243
+ * live - the session resolved. `session` is exactly what getSession() returns.
244
+ * missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
245
+ * store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
246
+ * nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
247
+ * a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
248
+ * unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
249
+ * so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
250
+ * none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
251
+ * Nothing to clear on our authority: a forged cookie never yields `missing`.
252
+ *
253
+ * The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
254
+ * either and an outage or a forged cookie becomes a clear.
255
+ */
256
+ export type SessionState =
257
+ | { state: 'live'; session: any }
258
+ | { state: 'missing'; sessionToken: string }
259
+ | { state: 'unknown' }
260
+ | { state: 'none' };
261
+
262
+ export async function resolveSessionState(request?: Request): Promise<SessionState> {
226
263
  const auth = await getAuthInstance();
227
- if (!request) return null;
264
+ if (!request) return { state: 'none' };
228
265
 
229
266
  // Path A — Better Auth primary path (cookie cache → secondary storage).
230
267
  // Fast happy-path when both Better Auth and Redis agree.
268
+ //
269
+ // PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
270
+ // could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
271
+ // `unknown`, not `none`.
231
272
  let betterAuthSession: any = null;
273
+ let primaryThrew = false;
232
274
  try {
233
275
  betterAuthSession = await auth.api.getSession({ headers: request.headers });
234
276
  } catch (err) {
277
+ primaryThrew = true;
235
278
  logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
236
279
  }
237
280
 
@@ -245,7 +288,7 @@ export async function getSession(request?: Request): Promise<any> {
245
288
  logSessionReadSwallowed('A', err); // Redis unavailable
246
289
  }
247
290
  if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
248
- return attachSessionData(betterAuthSession, sessionData, sessionToken);
291
+ return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
249
292
  }
250
293
 
251
294
  // Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
@@ -260,16 +303,21 @@ export async function getSession(request?: Request): Promise<any> {
260
303
  // the signature position, and a forged JWS too. The token now comes only from
261
304
  // the shared verified resolver (HMAC or JWS signature checked first).
262
305
  const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
263
- if (!sessionToken) return null;
306
+ // No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
307
+ // unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
308
+ if (!sessionToken) return primaryThrew ? { state: 'unknown' } : { state: 'none' };
264
309
 
310
+ // PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
311
+ // nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
265
312
  let sessionData: SessionData | null = null;
266
313
  try {
267
314
  sessionData = await getRedisSession(sessionToken);
268
315
  if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
269
316
  } catch (err) {
270
317
  logSessionReadSwallowed('B', err); // Redis unavailable
318
+ return { state: 'unknown' };
271
319
  }
272
- if (!sessionData) return null;
320
+ if (!sessionData) return { state: 'missing', sessionToken };
273
321
 
274
322
  // Synthesize a minimal session shape compatible with downstream callers.
275
323
  // The `session.token` field is what `getFreshIdpToken` and other callers
@@ -283,7 +331,7 @@ export async function getSession(request?: Request): Promise<any> {
283
331
  roles: sessionData.roles,
284
332
  },
285
333
  };
286
- return attachSessionData(synthetic, sessionData, sessionToken);
334
+ return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
287
335
  }
288
336
 
289
337
  /**