@payez/next-mvp 4.5.4 → 4.5.6

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.
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  export declare function GET(req: NextRequest): Promise<NextResponse<{
3
3
  canRefresh: boolean;
4
4
  reason: string;
5
- sessionTokenId: null;
5
+ error: string;
6
+ code: string;
6
7
  }> | NextResponse<{
7
8
  canRefresh: boolean;
8
9
  reason: string;
@@ -10,7 +11,5 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
10
11
  }> | NextResponse<{
11
12
  canRefresh: boolean;
12
13
  reason: string;
13
- error: string;
14
- code: string;
15
- correlationId: string;
14
+ sessionTokenId: null;
16
15
  }>>;
@@ -7,11 +7,29 @@ const refresh_token_validator_1 = require("../../lib/refresh-token-validator");
7
7
  const test_aware_get_token_1 = require("../../lib/test-aware-get-token");
8
8
  const logger_1 = require("../../config/logger");
9
9
  const token_log_id_1 = require("../../lib/token-log-id");
10
+ const auth_1 = require("../../server/auth");
10
11
  async function GET(req) {
11
12
  try {
12
13
  const sessionToken = req.nextUrl.searchParams.get('token');
13
14
  let finalSessionToken = sessionToken;
14
- if (!finalSessionToken) {
15
+ if (!finalSessionToken && process.env.TEST_MODE !== 'true') {
16
+ // PAY-2216: outside TEST_MODE getTokenTestAware is getSession(), which answered null for a store outage AND
17
+ // for a deleted session, so this route said not_logged_in to both and its own session_not_found arm (below)
18
+ // was unreachable from a cookie. Ask for the state instead. Same rules as routes/auth/viability.ts:
19
+ // 'unknown' never reads as logged out or stale; 'missing' requires a verified cookie.
20
+ const sessionState = await (0, auth_1.resolveSessionState)(req);
21
+ if (sessionState.state === 'unknown') {
22
+ logger_1.logger.warn('[REFRESH-VIABILITY] Session store unavailable - session state unknown');
23
+ return server_1.NextResponse.json({ canRefresh: false, reason: 'store_unavailable', error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
24
+ }
25
+ if (sessionState.state === 'missing') {
26
+ return server_1.NextResponse.json({ canRefresh: false, reason: 'session_not_found', sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionState.sessionToken) }, { status: 200 });
27
+ }
28
+ if (sessionState.state === 'live') {
29
+ finalSessionToken = sessionState.session?.session?.token ?? null;
30
+ }
31
+ }
32
+ else if (!finalSessionToken) {
15
33
  const token = await (0, test_aware_get_token_1.getTokenTestAware)(req);
16
34
  // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
17
35
  finalSessionToken = (token?.sessionToken || token?.redisSessionId);
@@ -39,8 +39,22 @@ async function GET(req) {
39
39
  code: 'AUTH_NOT_INITIALIZED'
40
40
  }, { status: 503 });
41
41
  }
42
- // Get session from Better Auth
43
- const betterAuthSession = await (0, auth_1.getSession)(req);
42
+ // PAY-2216: the TWIN of routes/auth/viability.ts - same tri-state, same answers, or an app mounting this export
43
+ // keeps the defect. 'unknown' (store did not answer) is a 5xx and never stale; 'missing' (verified cookie, store
44
+ // answered "not here") is the stale shape with a token id; 'none' stays { authenticated: false }.
45
+ const sessionState = await (0, auth_1.resolveSessionState)(req);
46
+ if (sessionState.state === 'unknown') {
47
+ console.warn('[VIABILITY] Session store unavailable - session state unknown');
48
+ return server_1.NextResponse.json({ authenticated: false, revocationStatus: 'unknown', error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
49
+ }
50
+ if (sessionState.state === 'missing') {
51
+ console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
52
+ return server_1.NextResponse.json({
53
+ authenticated: false,
54
+ sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionState.sessionToken) // PAY-1481: correlation id, never the token
55
+ });
56
+ }
57
+ const betterAuthSession = sessionState.state === 'live' ? sessionState.session : null;
44
58
  // Debug logging
45
59
  if (!betterAuthSession) {
46
60
  console.warn('[VIABILITY] getSession returned null');
@@ -64,6 +64,7 @@ const jose_1 = require("jose"); // better-auth's own JWT dependency
64
64
  const better_auth_1 = require("better-auth");
65
65
  const next_js_1 = require("better-auth/next-js");
66
66
  const next_js_2 = require("better-auth/next-js");
67
+ const session_response_sanitizer_1 = require("./session-response-sanitizer");
67
68
  const magic_link_1 = require("better-auth/plugins/magic-link");
68
69
  const client_key_1 = require("../lib/client-key");
69
70
  const generic_oauth_1 = require("better-auth/plugins/generic-oauth");
@@ -887,7 +888,10 @@ async function getBetterAuthHandler() {
887
888
  if (!isBetterAuthEnabled())
888
889
  return null;
889
890
  const auth = await getBetterAuthInstance();
890
- return (0, next_js_2.toNextJsHandler)(auth);
891
+ // PAY-2217: Better Auth's native endpoints (get-session, list-sessions, sign-in/*, sign-up/*) put the session
892
+ // token in the body. Strip it at the HTTP boundary here, so every consumer that mounts this handler gets it;
893
+ // auth.api (server-side) is untouched and still sees the token. See session-response-sanitizer.ts.
894
+ return (0, session_response_sanitizer_1.wrapBetterAuthHandler)(auth, (0, next_js_2.toNextJsHandler)(auth));
891
895
  }
892
896
  /**
893
897
  * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PAY-2217: no session token reaches browser JS from Better Auth's own HTTP endpoints.
3
+ *
4
+ * WHAT WAS WRONG. PAY-1481 removed token material from the package's own GET /api/auth/session. Better Auth's native
5
+ * endpoints are a separate surface served by the catch-all mount, and their default bodies carry the session
6
+ * credential identifier:
7
+ * GET /get-session -> { session: { id, token, userId, ... }, user }
8
+ * GET /list-sessions -> [ { id, token, ... } ] (EVERY session of the user, not just this one)
9
+ * POST /sign-in/* -> { token, user, ... }
10
+ * POST /sign-up/* -> { token, user }
11
+ * GET /magic-link/verify (no callbackURL) -> { token, user } (NightHawk G4 on PR 1190, measured on a real instance)
12
+ * and any plugin endpoint (passkey verify, two-factor verify, genericOAuth) answers a session object the same way.
13
+ * `token` is the Redis key of the session. The cookie is token.HMAC and Path B verifies the signature, so a bare
14
+ * token is not a replayable cookie (gate rows B1-B4 measure that), but it is a credential identifier readable by any
15
+ * script on the page, for this session and for every other session the user has. It breaks the PAY-1481 rule.
16
+ *
17
+ * EVERY 2xx JSON BODY IS STRIPPED, not a named list of paths. A list protects the endpoints someone remembered and
18
+ * silently misses the next plugin; the strip is a function of the response, so a new endpoint is covered the day it is
19
+ * mounted. A 2xx JSON body that legitimately needs one of these names to reach the browser would have to be
20
+ * allowlisted here on purpose (none is known: no client in this package reads one).
21
+ *
22
+ * WHERE THE FIX LIVES, AND WHY NOT customSession. Server-side code in this package reads session.session.token from
23
+ * auth.api.getSession() (viability, refresh, status, the OAuth exchange). A customSession plugin or an after-hook
24
+ * runs for those calls too and would strip the token from them. So the strip is applied at the HTTP BOUNDARY only:
25
+ * getBetterAuthHandler() returns handlers wrapped by wrapBetterAuthHandler(), and auth.api stays untouched.
26
+ *
27
+ * WHAT THE BROWSER GETS INSTEAD.
28
+ * - list-sessions rows carry `isCurrent` (computed here against the caller's cookie-resolved session) and `id`.
29
+ * - POST /revoke-session-by-id { id }: revokes ONE of the CALLER's sessions by its id. An id that is not one of the
30
+ * caller's own sessions answers 404 (never 403: do not confirm that another user's session id exists).
31
+ * - Better Auth's native POST /revoke-session { token } still exists and still requires a cookie-resolved session
32
+ * that owns the token, but no browser ever holds a token to send it.
33
+ */
34
+ /** Deep copy of `value` with every credential-named key removed, at any depth, in objects and arrays. */
35
+ export declare function stripSessionCredentials(value: unknown): unknown;
36
+ /** "/api/auth/get-session" -> "/get-session". Anything not under an /auth/ segment is returned as-is. */
37
+ export declare function authSubPath(pathname: string): string;
38
+ interface AuthApi {
39
+ getSession: (opts: {
40
+ headers: Headers;
41
+ }) => Promise<any>;
42
+ listSessions: (opts: {
43
+ headers: Headers;
44
+ }) => Promise<any>;
45
+ revokeSession: (opts: {
46
+ headers: Headers;
47
+ body: {
48
+ token: string;
49
+ };
50
+ asResponse?: boolean;
51
+ }) => Promise<any>;
52
+ }
53
+ /** Sanitise one already-produced Better Auth response. Pure function of (request, response, current token). */
54
+ export declare function sanitizeBetterAuthResponse(req: Request, res: Response, currentToken: () => Promise<string | undefined>): Promise<Response>;
55
+ type Handler = (req: Request) => Promise<Response>;
56
+ /**
57
+ * Wrap Better Auth's Next.js handlers so no response carries a session credential to the browser.
58
+ * `auth` is the Better Auth instance; its `api` is used server-side only (to find the caller's current session and to
59
+ * revoke by id) and is not modified.
60
+ */
61
+ export declare function wrapBetterAuthHandler(auth: {
62
+ api: AuthApi;
63
+ }, handlers: {
64
+ GET: Handler;
65
+ POST: Handler;
66
+ }): {
67
+ GET: Handler;
68
+ POST: Handler;
69
+ };
70
+ export {};
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * PAY-2217: no session token reaches browser JS from Better Auth's own HTTP endpoints.
4
+ *
5
+ * WHAT WAS WRONG. PAY-1481 removed token material from the package's own GET /api/auth/session. Better Auth's native
6
+ * endpoints are a separate surface served by the catch-all mount, and their default bodies carry the session
7
+ * credential identifier:
8
+ * GET /get-session -> { session: { id, token, userId, ... }, user }
9
+ * GET /list-sessions -> [ { id, token, ... } ] (EVERY session of the user, not just this one)
10
+ * POST /sign-in/* -> { token, user, ... }
11
+ * POST /sign-up/* -> { token, user }
12
+ * GET /magic-link/verify (no callbackURL) -> { token, user } (NightHawk G4 on PR 1190, measured on a real instance)
13
+ * and any plugin endpoint (passkey verify, two-factor verify, genericOAuth) answers a session object the same way.
14
+ * `token` is the Redis key of the session. The cookie is token.HMAC and Path B verifies the signature, so a bare
15
+ * token is not a replayable cookie (gate rows B1-B4 measure that), but it is a credential identifier readable by any
16
+ * script on the page, for this session and for every other session the user has. It breaks the PAY-1481 rule.
17
+ *
18
+ * EVERY 2xx JSON BODY IS STRIPPED, not a named list of paths. A list protects the endpoints someone remembered and
19
+ * silently misses the next plugin; the strip is a function of the response, so a new endpoint is covered the day it is
20
+ * mounted. A 2xx JSON body that legitimately needs one of these names to reach the browser would have to be
21
+ * allowlisted here on purpose (none is known: no client in this package reads one).
22
+ *
23
+ * WHERE THE FIX LIVES, AND WHY NOT customSession. Server-side code in this package reads session.session.token from
24
+ * auth.api.getSession() (viability, refresh, status, the OAuth exchange). A customSession plugin or an after-hook
25
+ * runs for those calls too and would strip the token from them. So the strip is applied at the HTTP BOUNDARY only:
26
+ * getBetterAuthHandler() returns handlers wrapped by wrapBetterAuthHandler(), and auth.api stays untouched.
27
+ *
28
+ * WHAT THE BROWSER GETS INSTEAD.
29
+ * - list-sessions rows carry `isCurrent` (computed here against the caller's cookie-resolved session) and `id`.
30
+ * - POST /revoke-session-by-id { id }: revokes ONE of the CALLER's sessions by its id. An id that is not one of the
31
+ * caller's own sessions answers 404 (never 403: do not confirm that another user's session id exists).
32
+ * - Better Auth's native POST /revoke-session { token } still exists and still requires a cookie-resolved session
33
+ * that owns the token, but no browser ever holds a token to send it.
34
+ */
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.stripSessionCredentials = stripSessionCredentials;
37
+ exports.authSubPath = authSubPath;
38
+ exports.sanitizeBetterAuthResponse = sanitizeBetterAuthResponse;
39
+ exports.wrapBetterAuthHandler = wrapBetterAuthHandler;
40
+ /** Credential-bearing key names, compared case- and separator-insensitively. */
41
+ const STRIP_KEYS = new Set(['token', 'sessiontoken', 'accesstoken', 'refreshtoken', 'idtoken']);
42
+ const norm = (k) => k.toLowerCase().replace(/[_\-\s]/g, '');
43
+ /** Deep copy of `value` with every credential-named key removed, at any depth, in objects and arrays. */
44
+ function stripSessionCredentials(value) {
45
+ if (Array.isArray(value))
46
+ return value.map(stripSessionCredentials);
47
+ if (value !== null && typeof value === 'object') {
48
+ const out = {};
49
+ for (const [k, v] of Object.entries(value)) {
50
+ if (STRIP_KEYS.has(norm(k)))
51
+ continue;
52
+ out[k] = stripSessionCredentials(v);
53
+ }
54
+ return out;
55
+ }
56
+ return value;
57
+ }
58
+ /** "/api/auth/get-session" -> "/get-session". Anything not under an /auth/ segment is returned as-is. */
59
+ function authSubPath(pathname) {
60
+ const i = pathname.indexOf('/api/auth/');
61
+ if (i >= 0)
62
+ return pathname.slice(i + '/api/auth'.length).replace(/\/+$/, '') || '/';
63
+ return pathname.replace(/\/+$/, '') || '/';
64
+ }
65
+ function isJson(res) {
66
+ return (res.headers.get('content-type') || '').toLowerCase().includes('application/json');
67
+ }
68
+ /** A Response with a new JSON body and the original status and headers (content-length recomputed by the runtime). */
69
+ function withJsonBody(res, body) {
70
+ const headers = new Headers(res.headers);
71
+ headers.delete('content-length');
72
+ return new Response(JSON.stringify(body), { status: res.status, statusText: res.statusText, headers });
73
+ }
74
+ /** Sanitise one already-produced Better Auth response. Pure function of (request, response, current token). */
75
+ async function sanitizeBetterAuthResponse(req, res, currentToken) {
76
+ const sub = authSubPath(new URL(req.url).pathname);
77
+ if (!res.ok || !isJson(res))
78
+ return res;
79
+ let body;
80
+ try {
81
+ body = await res.clone().json();
82
+ }
83
+ catch {
84
+ return res; // unreadable body: nothing to strip, and do not turn it into a different failure
85
+ }
86
+ if (sub === '/list-sessions' && Array.isArray(body)) {
87
+ const cur = await currentToken().catch(() => undefined);
88
+ body = body.map((row) => row && typeof row === 'object' ? { ...row, isCurrent: !!cur && row.token === cur } : row);
89
+ }
90
+ return withJsonBody(res, stripSessionCredentials(body));
91
+ }
92
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
93
+ /** POST /revoke-session-by-id { id }: revoke one of the caller's own sessions by id. */
94
+ async function revokeById(req, api) {
95
+ let id;
96
+ try {
97
+ id = (await req.json())?.id;
98
+ }
99
+ catch {
100
+ return json(400, { code: 'INVALID_REQUEST', message: 'Body must be JSON { id }' });
101
+ }
102
+ if (typeof id !== 'string' || id.length === 0) {
103
+ return json(400, { code: 'INVALID_REQUEST', message: 'id is required' });
104
+ }
105
+ const headers = new Headers(req.headers);
106
+ const rows = await api.listSessions({ headers }).catch(() => null);
107
+ if (rows === null)
108
+ return json(401, { code: 'UNAUTHORIZED', message: 'No session' });
109
+ // listSessions returns ONLY the caller's own sessions, so a foreign id cannot match: 404, not 403.
110
+ const row = Array.isArray(rows) ? rows.find((r) => r && r.id === id && typeof r.token === 'string') : undefined;
111
+ if (!row)
112
+ return json(404, { code: 'SESSION_NOT_FOUND', message: 'No such session' });
113
+ const res = await api.revokeSession({ headers, body: { token: row.token }, asResponse: true });
114
+ // Pass Better Auth's own answer (and its Set-Cookie when the current session was the one revoked) through.
115
+ return res instanceof Response ? res : json(200, { status: true });
116
+ }
117
+ /**
118
+ * Wrap Better Auth's Next.js handlers so no response carries a session credential to the browser.
119
+ * `auth` is the Better Auth instance; its `api` is used server-side only (to find the caller's current session and to
120
+ * revoke by id) and is not modified.
121
+ */
122
+ function wrapBetterAuthHandler(auth, handlers) {
123
+ const currentTokenFor = (req) => async () => {
124
+ const s = await auth.api.getSession({ headers: new Headers(req.headers) });
125
+ return s?.session?.token;
126
+ };
127
+ return {
128
+ GET: async (req) => sanitizeBetterAuthResponse(req, await handlers.GET(req), currentTokenFor(req)),
129
+ POST: async (req) => {
130
+ const sub = authSubPath(new URL(req.url).pathname);
131
+ if (sub === '/revoke-session-by-id')
132
+ return revokeById(req, auth.api);
133
+ return sanitizeBetterAuthResponse(req, await handlers.POST(req), currentTokenFor(req));
134
+ },
135
+ };
136
+ }
@@ -24,6 +24,13 @@ import { type RevocationStatus } from '../../vibe/sessions';
24
24
  * - expiresIn: number - Seconds until token expires
25
25
  */
26
26
  export declare function GET(req: NextRequest): Promise<NextResponse<{
27
+ viable: boolean;
28
+ needsRefresh: boolean;
29
+ authenticated: boolean;
30
+ revocationStatus: RevocationStatus;
31
+ error: string;
32
+ code: string;
33
+ }> | NextResponse<{
27
34
  viable: boolean;
28
35
  needsRefresh: boolean;
29
36
  authenticated: boolean;
@@ -49,12 +56,4 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
49
56
  expiresAt: string;
50
57
  roles: string[];
51
58
  clientId: string | null;
52
- }> | NextResponse<{
53
- viable: boolean;
54
- needsRefresh: boolean;
55
- authenticated: boolean;
56
- revocationStatus: RevocationStatus;
57
- error: string;
58
- code: string;
59
- correlationId: string;
60
59
  }>>;
@@ -136,8 +136,37 @@ function resolveClientId(sessionClientId) {
136
136
  */
137
137
  async function GET(req) {
138
138
  try {
139
- const baSession = await (0, auth_1.getSession)(req);
140
- if (!baSession) {
139
+ // PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
140
+ // session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
141
+ // never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
142
+ const sessionState = await (0, auth_1.resolveSessionState)(req);
143
+ if (sessionState.state === 'unknown') {
144
+ // The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
145
+ // Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
146
+ // path, a non-OK answer -> service_error, which never clears cookies.
147
+ console.warn('[VIABILITY] Session store unavailable - session state unknown');
148
+ return server_1.NextResponse.json({
149
+ viable: false,
150
+ needsRefresh: false,
151
+ authenticated: false,
152
+ revocationStatus: 'unknown',
153
+ error: 'Session store unavailable',
154
+ code: 'STORE_UNAVAILABLE'
155
+ }, { status: 500 });
156
+ }
157
+ if (sessionState.state === 'missing') {
158
+ // A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
159
+ // stale_session arm (PAY-2216 / 4.5.4) clears it.
160
+ console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
161
+ return server_1.NextResponse.json({
162
+ viable: false,
163
+ needsRefresh: false,
164
+ authenticated: false,
165
+ sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
166
+ reason: 'Stale session - cookie exists but session not found in Redis'
167
+ });
168
+ }
169
+ if (sessionState.state === 'none') {
141
170
  return server_1.NextResponse.json({
142
171
  viable: false,
143
172
  needsRefresh: false,
@@ -145,6 +174,7 @@ async function GET(req) {
145
174
  reason: 'No session found'
146
175
  });
147
176
  }
177
+ const baSession = sessionState.session;
148
178
  const token = baSession;
149
179
  const sessionToken = baSession.session?.token;
150
180
  // Try the canonical session-store first; fall back to the Better-Auth-keyed
@@ -78,8 +78,46 @@ export declare function getAuthInstance(): Promise<any>;
78
78
  * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
79
79
  *
80
80
  * Returns the session object or null if not authenticated per Redis.
81
+ *
82
+ * PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
83
+ * Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
84
+ * unreachable store call resolveSessionState directly.
81
85
  */
82
86
  export declare function getSession(request?: Request): Promise<any>;
87
+ /**
88
+ * PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
89
+ * non-live answers to `null`, and that collapse is the defect this type exists to undo.
90
+ *
91
+ * MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
92
+ * a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
93
+ * identical `200 No session found` for both. Clearing on that shape would log every live user out on an
94
+ * outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
95
+ *
96
+ * live - the session resolved. `session` is exactly what getSession() returns.
97
+ * missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
98
+ * store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
99
+ * nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
100
+ * a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
101
+ * unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
102
+ * so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
103
+ * none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
104
+ * Nothing to clear on our authority: a forged cookie never yields `missing`.
105
+ *
106
+ * The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
107
+ * either and an outage or a forged cookie becomes a clear.
108
+ */
109
+ export type SessionState = {
110
+ state: 'live';
111
+ session: any;
112
+ } | {
113
+ state: 'missing';
114
+ sessionToken: string;
115
+ } | {
116
+ state: 'unknown';
117
+ } | {
118
+ state: 'none';
119
+ };
120
+ export declare function resolveSessionState(request?: Request): Promise<SessionState>;
83
121
  /**
84
122
  * Get normalized session data for the current request.
85
123
  *
@@ -41,6 +41,7 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.getAuthInstance = getAuthInstance;
43
43
  exports.getSession = getSession;
44
+ exports.resolveSessionState = resolveSessionState;
44
45
  exports.getSessionData = getSessionData;
45
46
  exports.getIdpToken = getIdpToken;
46
47
  exports.getFreshIdpToken = getFreshIdpToken;
@@ -230,18 +231,32 @@ function logNoSessionWithKeys(origin, sessionToken) {
230
231
  * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
231
232
  *
232
233
  * Returns the session object or null if not authenticated per Redis.
234
+ *
235
+ * PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
236
+ * Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
237
+ * unreachable store call resolveSessionState directly.
233
238
  */
234
239
  async function getSession(request) {
240
+ const state = await resolveSessionState(request);
241
+ return state.state === 'live' ? state.session : null;
242
+ }
243
+ async function resolveSessionState(request) {
235
244
  const auth = await getAuthInstance();
236
245
  if (!request)
237
- return null;
246
+ return { state: 'none' };
238
247
  // Path A — Better Auth primary path (cookie cache → secondary storage).
239
248
  // Fast happy-path when both Better Auth and Redis agree.
249
+ //
250
+ // PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
251
+ // could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
252
+ // `unknown`, not `none`.
240
253
  let betterAuthSession = null;
254
+ let primaryThrew = false;
241
255
  try {
242
256
  betterAuthSession = await auth.api.getSession({ headers: request.headers });
243
257
  }
244
258
  catch (err) {
259
+ primaryThrew = true;
245
260
  logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
246
261
  }
247
262
  if (betterAuthSession?.session?.token && betterAuthSession?.user) {
@@ -257,7 +272,7 @@ async function getSession(request) {
257
272
  }
258
273
  if (!sessionData)
259
274
  sessionData = buildSessionDataFromAuthSession(betterAuthSession);
260
- return attachSessionData(betterAuthSession, sessionData, sessionToken);
275
+ return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
261
276
  }
262
277
  // Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
263
278
  // decode + Redis-canonical liveness check. This catches cases where the
@@ -271,8 +286,12 @@ async function getSession(request) {
271
286
  // the signature position, and a forged JWS too. The token now comes only from
272
287
  // the shared verified resolver (HMAC or JWS signature checked first).
273
288
  const sessionToken = await (0, verified_session_token_1.resolveVerifiedSessionTokenFromRequest)(request);
289
+ // No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
290
+ // unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
274
291
  if (!sessionToken)
275
- return null;
292
+ return primaryThrew ? { state: 'unknown' } : { state: 'none' };
293
+ // PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
294
+ // nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
276
295
  let sessionData = null;
277
296
  try {
278
297
  sessionData = await (0, session_store_1.getSession)(sessionToken);
@@ -281,9 +300,10 @@ async function getSession(request) {
281
300
  }
282
301
  catch (err) {
283
302
  logSessionReadSwallowed('B', err); // Redis unavailable
303
+ return { state: 'unknown' };
284
304
  }
285
305
  if (!sessionData)
286
- return null;
306
+ return { state: 'missing', sessionToken };
287
307
  // Synthesize a minimal session shape compatible with downstream callers.
288
308
  // The `session.token` field is what `getFreshIdpToken` and other callers
289
309
  // read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
@@ -296,7 +316,7 @@ async function getSession(request) {
296
316
  roles: sessionData.roles,
297
317
  },
298
318
  };
299
- return attachSessionData(synthetic, sessionData, sessionToken);
319
+ return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
300
320
  }
301
321
  /**
302
322
  * Get normalized session data for the current request.
@@ -425,25 +425,12 @@ exports.useAuthStore = (0, zustand_1.create)()((0, middleware_1.devtools)((set,
425
425
  try {
426
426
  // COORDINATED REFRESH: The server now handles distributed locking
427
427
  // We just need to make the refresh call and let the server coordinate
428
- // Resolve session token for header
429
- const resolveSessionTokenForHeader = async () => {
430
- let token = get().session?.sessionToken;
431
- if (token)
432
- return token;
433
- try {
434
- for (let attempt = 1; attempt <= 3 && !token; attempt++) {
435
- const { data: s } = await better_auth_client_1.authClient.getSession();
436
- token = s?.sessionToken;
437
- if (!token) {
438
- await new Promise(r => setTimeout(r, 150));
439
- }
440
- }
441
- }
442
- catch {
443
- logger_1.authLogger.warn('[AuthStore] Failed to resolve session token from Better Auth during refresh');
444
- }
445
- return token;
446
- };
428
+ // PAY-2217: the session token is NOT read from Better Auth's getSession() any more. The HTTP body no
429
+ // longer carries it (wrapBetterAuthHandler), and this read was already dead: getSession() returns
430
+ // { session: { token } }, never a top-level `sessionToken`, so the 3 x 150 ms retry loop that used to sit
431
+ // here always ran to exhaustion. The server resolves the session from the cookie and REJECTS an
432
+ // x-session-token header when it cannot (refresh.ts, 246007), so the header is advisory at best.
433
+ const resolveSessionTokenForHeader = async () => get().session?.sessionToken;
447
434
  const sessionTokenHeader = await resolveSessionTokenForHeader();
448
435
  const refreshResponse = await fetch('/api/auth/refresh', {
449
436
  method: 'POST',