@payez/next-mvp 4.1.11 β†’ 4.1.12

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.
Files changed (91) hide show
  1. package/dist/api-handlers/auth/refresh.js +15 -38
  2. package/dist/auth/better-auth.js +12 -33
  3. package/dist/client/better-auth-client.d.ts +14 -2
  4. package/dist/lib/ensure-fresh-access-token.js +8 -42
  5. package/dist/lib/session-store.d.ts +0 -1
  6. package/dist/lib/session-store.js +29 -33
  7. package/dist/lib/startup-init.js +19 -19
  8. package/dist/lib/token-lifecycle.d.ts +0 -2
  9. package/dist/lib/token-lifecycle.js +3 -21
  10. package/package.json +4 -3
  11. package/scripts/check-internal-url-usage.sh +73 -73
  12. package/src/api/index.ts +17 -17
  13. package/src/api-handlers/account/masked-info.ts +45 -45
  14. package/src/api-handlers/account/profile.ts +80 -80
  15. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  16. package/src/api-handlers/account/recovery/send-code.ts +25 -25
  17. package/src/api-handlers/account/recovery/verify-code.ts +25 -25
  18. package/src/api-handlers/account/reset-password.ts +23 -23
  19. package/src/api-handlers/account/send-code.ts +76 -76
  20. package/src/api-handlers/account/validate-password.ts +118 -118
  21. package/src/api-handlers/admin/stats.ts +249 -249
  22. package/src/api-handlers/anon/preferences.ts +123 -123
  23. package/src/api-handlers/auth/jwks.ts +20 -20
  24. package/src/api-handlers/auth/login.ts +240 -240
  25. package/src/api-handlers/auth/refresh.ts +15 -38
  26. package/src/api-handlers/auth/validate.ts +43 -43
  27. package/src/api-handlers/session/viability.ts +142 -142
  28. package/src/auth/better-auth.ts +450 -467
  29. package/src/auth/unauthenticated-routes.ts +2 -2
  30. package/src/client/better-auth-client.ts +70 -70
  31. package/src/client/fetchWithSession.ts +21 -21
  32. package/src/client/index.ts +13 -13
  33. package/src/client/useAnonSession.ts +131 -131
  34. package/src/components/admin/VibeAdminContext.tsx +87 -87
  35. package/src/config/logging-config.ts +82 -82
  36. package/src/config/unauthenticated-routes.ts +19 -19
  37. package/src/edge/internal-api-url.ts +65 -65
  38. package/src/edge/middleware.ts +42 -42
  39. package/src/hooks/useProfile.ts +75 -75
  40. package/src/lib/anon-session.ts +213 -213
  41. package/src/lib/auth-secret.ts +137 -137
  42. package/src/lib/demo-mode.ts +17 -17
  43. package/src/lib/ensure-fresh-access-token.ts +8 -42
  44. package/src/lib/idp-client-config.ts +559 -559
  45. package/src/lib/idp-fetch.ts +126 -126
  46. package/src/lib/internal-api.ts +171 -171
  47. package/src/lib/jwt-decode-client.ts +45 -45
  48. package/src/lib/rate-limit-service.ts +9 -9
  49. package/src/lib/redis.ts +26 -26
  50. package/src/lib/refresh-token-validator.ts +64 -64
  51. package/src/lib/secret-validation.ts +8 -8
  52. package/src/lib/session-store.ts +692 -695
  53. package/src/lib/session.ts +34 -34
  54. package/src/lib/startup-init.ts +251 -251
  55. package/src/lib/token-expiry.ts +40 -40
  56. package/src/lib/token-lifecycle.ts +470 -497
  57. package/src/lib/types/api-responses.ts +336 -336
  58. package/src/middleware/auth-decision.ts +43 -43
  59. package/src/models/DecodedAccessToken.ts +16 -16
  60. package/src/models/SessionModel.ts +264 -264
  61. package/src/pages/admin-roles/index.ts +9 -9
  62. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  63. package/src/routes/account/index.ts +31 -31
  64. package/src/routes/account/masked-info.ts +36 -36
  65. package/src/routes/account/send-code.ts +39 -39
  66. package/src/routes/account/verify-email.ts +40 -40
  67. package/src/routes/account/verify-sms.ts +40 -40
  68. package/src/routes/auth/index.ts +22 -22
  69. package/src/routes/auth/refresh.ts +48 -48
  70. package/src/routes/auth/session.ts +166 -166
  71. package/src/routes/index.ts +17 -17
  72. package/src/routes/session/index.ts +7 -7
  73. package/src/routes/session/refresh-viability.ts +17 -17
  74. package/src/server/auth.ts +373 -373
  75. package/src/server/decode-session.ts +202 -202
  76. package/src/services/signalrActivityService.ts +258 -258
  77. package/src/theme/ThemeProvider.tsx +39 -39
  78. package/src/theme/default.ts +33 -33
  79. package/src/theme/index.ts +31 -31
  80. package/src/theme/types.ts +69 -69
  81. package/src/theme/useTheme.ts +57 -57
  82. package/src/theme/utils.ts +40 -40
  83. package/src/types/api.ts +12 -12
  84. package/src/types/auth.d.ts +23 -23
  85. package/src/types/auth.ts +22 -22
  86. package/src/types/logging.ts +11 -11
  87. package/src/types/recovery.ts +54 -54
  88. package/src/utils/api.ts +353 -353
  89. package/src/utils/circuitBreaker.ts +40 -40
  90. package/src/utils/error-message.ts +108 -108
  91. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -371,7 +371,7 @@ function createRefreshHandler(config) {
371
371
  const errorMessage = idpError.message || 'Token refresh failed';
372
372
  // CRITICAL: 401 means token is definitively invalid - always discard
373
373
  // This prevents redirect loops where viability check keeps returning canRefresh:true
374
- let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
374
+ const discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
375
375
  const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
376
376
  const resolution = idpError.resolution || null;
377
377
  // ============================================================================
@@ -408,43 +408,20 @@ function createRefreshHandler(config) {
408
408
  console.error('[AUTH_REFRESH] Full IDP error response:', JSON.stringify(responseData, null, 2).substring(0, 1000));
409
409
  // If IDP signals to discard token, clear it from Redis
410
410
  if (discardToken) {
411
- // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
412
- // the current one. A concurrent refresh may have already rotated it β€” our 401
413
- // is then stale, and wiping here would silently log out a healthy session
414
- // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
415
- const postRefresh = await (0, session_store_1.getSession)(sessionToken);
416
- const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
417
- if (tokenUnchanged) {
418
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
419
- reason: 'IDP_DISCARD_TOKEN',
420
- errorCode,
421
- userId,
422
- sessionToken: sessionToken.substring(0, 8) + '...',
423
- hadRefreshToken: !!currentSession.idpRefreshToken,
424
- stack: new Error().stack
425
- });
426
- await (0, session_store_1.updateSession)(sessionToken, {
427
- idpRefreshToken: '',
428
- idpRefreshTokenExpires: undefined,
429
- refreshTokenClearedAt: Date.now(),
430
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
431
- });
432
- }
433
- else {
434
- // Concurrent refresh already rotated the token β€” our 401 is stale. Return the
435
- // freshly-rotated token directly so the loser succeeds without a retry
436
- // (race-fix follow-up #1).
437
- discardToken = false;
438
- console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh β€” returning the fresh token (race-safe).');
439
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
440
- return server_1.NextResponse.json({
441
- refreshed: true,
442
- reason: 'completed_by_concurrent_request',
443
- accessTokenExpires: postRefresh.idpAccessTokenExpires,
444
- hasRefreshToken: !!postRefresh.idpRefreshToken,
445
- });
446
- }
447
- }
411
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
412
+ reason: 'IDP_DISCARD_TOKEN',
413
+ errorCode,
414
+ userId,
415
+ sessionToken: sessionToken.substring(0, 8) + '...',
416
+ hadRefreshToken: !!currentSession.idpRefreshToken,
417
+ stack: new Error().stack
418
+ });
419
+ await (0, session_store_1.updateSession)(sessionToken, {
420
+ idpRefreshToken: '',
421
+ idpRefreshTokenExpires: undefined,
422
+ refreshTokenClearedAt: Date.now(),
423
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
424
+ });
448
425
  }
449
426
  return server_1.NextResponse.json({
450
427
  error: errorMessage,
@@ -60,7 +60,6 @@ const magic_link_1 = require("better-auth/plugins/magic-link");
60
60
  const idp_client_config_1 = require("../lib/idp-client-config");
61
61
  const app_slug_1 = require("../lib/app-slug");
62
62
  const redis_1 = require("../lib/redis");
63
- const session_store_1 = require("../lib/session-store");
64
63
  /**
65
64
  * Build Better Auth social providers from IDP config.
66
65
  */
@@ -126,24 +125,16 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
126
125
  // Redis-backed session storage via secondaryStorage
127
126
  secondaryStorage: {
128
127
  get: async (key) => {
129
- try {
130
- return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
131
- }
132
- catch {
133
- return null;
134
- }
128
+ return await (0, redis_1.getRedis)().get(`ba:${appSlug}:${key}`);
135
129
  },
136
130
  set: async (key, value, ttl) => {
137
- try {
138
- const redis = (0, redis_1.getRedis)();
139
- if (ttl) {
140
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
141
- }
142
- else {
143
- await redis.setex(`ba:${appSlug}:${key}`, session_store_1.SESSION_TTL, value);
144
- }
131
+ const redis = (0, redis_1.getRedis)();
132
+ if (ttl) {
133
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
134
+ }
135
+ else {
136
+ await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
145
137
  }
146
- catch { /* Redis unavailable β€” cookie cache still works */ }
147
138
  },
148
139
  delete: async (key) => {
149
140
  try {
@@ -327,20 +318,8 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
327
318
  return false;
328
319
  }
329
320
  // Build IDP token data
330
- // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
331
- // !hasRefreshToken was a reliable "2FA required" signal.
332
- //
333
- // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
334
- // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
335
- // This made !hasRefreshToken=false β†’ requiresTwoFactor=false β†’ mfaVerified=true,
336
- // bypassing the 2FA gate entirely.
337
- //
338
- // FIX: Always set mfaVerified=false on the initial OAuth exchange.
339
- // The tenant's require_2fa setting is enforced by the viability endpoint
340
- // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
341
- // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
342
- // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
343
- // IDP correctly withholds refresh_token until post-2FA.
321
+ // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
322
+ // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
344
323
  const hasRefreshToken = !!result.refresh_token;
345
324
  const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
346
325
  const idpTokenData = {
@@ -354,19 +333,19 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
354
333
  name: result.user?.full_name || result.user?.name || result.name || name,
355
334
  image: image,
356
335
  roles: result.user?.roles || result.roles || [],
357
- mfaVerified: false, // Always false post-OAuth β€” see IDP-2FA-BUG note above
336
+ mfaVerified: !requiresTwoFactor,
358
337
  idpClientId: result.client_id ? String(result.client_id) : undefined,
359
338
  merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
360
339
  };
361
340
  // Store in BA Redis session (for decodeSession)
362
341
  baData.idpTokens = idpTokenData;
363
- await (0, redis_1.getRedis)().setex(baKey, session_store_1.SESSION_TTL, JSON.stringify(baData));
342
+ await (0, redis_1.getRedis)().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
364
343
  // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
365
344
  // Key format: {sessionPrefix}{token} β€” same key that getSession() reads from.
366
345
  try {
367
346
  const { getSessionPrefix } = await Promise.resolve().then(() => __importStar(require('../lib/app-slug')));
368
347
  const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
369
- await (0, redis_1.getRedis)().setex(canonicalKey, session_store_1.SESSION_TTL, JSON.stringify({
348
+ await (0, redis_1.getRedis)().setex(canonicalKey, 7 * 24 * 60 * 60, JSON.stringify({
370
349
  ...idpTokenData,
371
350
  oauthProvider: provider,
372
351
  }));
@@ -814,7 +814,13 @@ export declare const authClient: {
814
814
  isPending: boolean;
815
815
  isRefetching: boolean;
816
816
  error: import("@better-fetch/fetch").BetterFetchError | null;
817
- refetch: (queryParams?: {
817
+ refetch: (queryParams
818
+ /**
819
+ * NextAuth-compatible signOut wrapper.
820
+ *
821
+ * Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
822
+ */
823
+ ?: {
818
824
  query?: import("better-auth/types").SessionQueryParams;
819
825
  } | undefined) => Promise<void>;
820
826
  };
@@ -988,7 +994,13 @@ export declare const useSession: () => {
988
994
  isPending: boolean;
989
995
  isRefetching: boolean;
990
996
  error: import("@better-fetch/fetch").BetterFetchError | null;
991
- refetch: (queryParams?: {
997
+ refetch: (queryParams
998
+ /**
999
+ * NextAuth-compatible signOut wrapper.
1000
+ *
1001
+ * Maps NextAuth signOut({ redirect, callbackUrl }) to Better Auth.
1002
+ */
1003
+ ?: {
992
1004
  query?: import("better-auth/types").SessionQueryParams;
993
1005
  } | undefined) => Promise<void>;
994
1006
  }, signIn: {
@@ -158,16 +158,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
158
158
  if (isOAuthSession && session.mfaVerified && acrValue === '1') {
159
159
  acrValue = '2';
160
160
  }
161
- // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
162
- // `session` was read before we acquired the lock; a concurrent refresh that
163
- // completed while we waited rotates the token, so the pre-lock value is stale
164
- // and would draw an avoidable 401 (the CAS now catches the resulting wipe, but
165
- // the round-trip is wasted). `latest` is the authoritative post-lock session.
166
- // Auth-context (amr/acr/2FA) intentionally still derives from `session` β€” it is
167
- // the identity context of THIS request, unchanged by a token rotation.
168
- const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
169
161
  const body = {
170
- refresh_token: sentRefreshToken,
162
+ refresh_token: session.idpRefreshToken,
171
163
  amr: authMethods,
172
164
  acr: acrValue,
173
165
  };
@@ -208,41 +200,15 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
208
200
  if (!idpResponse.ok) {
209
201
  const idpError = responseData?.error || {};
210
202
  const code = idpError.code || 'UNKNOWN_ERROR';
211
- let discardToken = idpResponse.status === 401 || idpError.discard_token === true;
203
+ const discardToken = idpResponse.status === 401 || idpError.discard_token === true;
212
204
  const retryable = idpResponse.status !== 401 && idpError.retryable === true;
213
205
  if (discardToken) {
214
- // Race-safe discard (CAS): only clear if the refresh token we used is STILL
215
- // current. A concurrent refresh may have already rotated it β€” our 401 is then
216
- // stale/expected, and wiping here would silently log out a healthy session
217
- // (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
218
- // a concurrent rotation is NOT a terminal condition.
219
- const postRefresh = await (0, session_store_1.getSession)(sessionToken);
220
- // CAS compares against the token we ACTUALLY SENT (WO-2: sentRefreshToken,
221
- // the post-lock value), not the pre-lock snapshot β€” otherwise a token rotated
222
- // between pre-lock read and our send would be misjudged.
223
- const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
224
- if (tokenUnchanged) {
225
- await (0, session_store_1.updateSession)(sessionToken, {
226
- idpRefreshToken: '',
227
- idpRefreshTokenExpires: undefined,
228
- refreshTokenClearedAt: Date.now(),
229
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
230
- });
231
- }
232
- else {
233
- // Concurrent refresh already rotated the token β€” our 401 is stale. Return the
234
- // freshly-rotated token directly so the loser succeeds without a retry round-trip
235
- // (race-fix follow-up #1).
236
- discardToken = false;
237
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
238
- return {
239
- ok: true,
240
- accessToken: postRefresh.idpAccessToken,
241
- accessTokenExpires: postRefresh.idpAccessTokenExpires,
242
- refreshed: true,
243
- };
244
- }
245
- }
206
+ await (0, session_store_1.updateSession)(sessionToken, {
207
+ idpRefreshToken: '',
208
+ idpRefreshTokenExpires: undefined,
209
+ refreshTokenClearedAt: Date.now(),
210
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
211
+ });
246
212
  }
247
213
  return {
248
214
  ok: false,
@@ -9,7 +9,6 @@
9
9
  */
10
10
  import { SessionData } from '../models/SessionModel';
11
11
  export type { SessionData } from '../models/SessionModel';
12
- export declare const SESSION_TTL: number;
13
12
  export interface RefreshLockInfo {
14
13
  sessionToken: string;
15
14
  acquiredAt: number;
@@ -12,7 +12,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.SESSION_TTL = void 0;
16
15
  exports.generateSessionToken = generateSessionToken;
17
16
  exports.createSession = createSession;
18
17
  exports.getSession = getSession;
@@ -47,10 +46,7 @@ const getSessionVersionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}ve
47
46
  // Better Auth uses a different key format: ba:{appSlug}:{token}
48
47
  const getBetterAuthSessionKey = (token, appSlug) => `ba:${appSlug || 'app'}:${token}`;
49
48
  const REFRESH_LOCK_TTL = 60; // 60 seconds
50
- // 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
51
- // of session TTL β€” better-auth.ts derives its BA-session Redis TTL from this so the
52
- // two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
53
- exports.SESSION_TTL = 3 * 24 * 60 * 60;
49
+ const SESSION_TTL = 3 * 24 * 60 * 60; // 3 days in seconds (matches refresh token lifetime)
54
50
  /**
55
51
  * Generates a new session token.
56
52
  * @returns A new session token string.
@@ -70,8 +66,8 @@ async function createSession(data) {
70
66
  const versionKey = getSessionVersionKey(sessionToken);
71
67
  try {
72
68
  await redis_1.default.multi()
73
- .setex(key, exports.SESSION_TTL, JSON.stringify(data))
74
- .setex(versionKey, exports.SESSION_TTL, '1')
69
+ .setex(key, SESSION_TTL, JSON.stringify(data))
70
+ .setex(versionKey, SESSION_TTL, '1')
75
71
  .exec();
76
72
  }
77
73
  catch (error) {
@@ -152,7 +148,7 @@ async function getBetterAuthSession(sessionToken, appSlug) {
152
148
  */
153
149
  async function touchSession(token) {
154
150
  const key = getSessionKey(token);
155
- await redis_1.default.expire(key, exports.SESSION_TTL);
151
+ await redis_1.default.expire(key, SESSION_TTL);
156
152
  }
157
153
  /**
158
154
  * Retrieves a session along with a version identifier for optimistic locking.
@@ -218,9 +214,9 @@ async function setSession(sessionToken, data) {
218
214
  const key = getSessionKey(sessionToken);
219
215
  const versionKey = getSessionVersionKey(sessionToken);
220
216
  await redis_1.default.multi()
221
- .setex(key, exports.SESSION_TTL, JSON.stringify(data))
217
+ .setex(key, SESSION_TTL, JSON.stringify(data))
222
218
  .incr(versionKey)
223
- .expire(versionKey, exports.SESSION_TTL)
219
+ .expire(versionKey, SESSION_TTL)
224
220
  .exec();
225
221
  }
226
222
  /**
@@ -258,9 +254,9 @@ async function updateSession(sessionToken, updates) {
258
254
  const updatedSession = { ...currentSession, ...updates };
259
255
  // Write the entire updated session back to Redis with version increment
260
256
  await redis_1.default.multi()
261
- .setex(key, exports.SESSION_TTL, JSON.stringify(updatedSession))
257
+ .setex(key, SESSION_TTL, JSON.stringify(updatedSession))
262
258
  .incr(versionKey)
263
- .expire(versionKey, exports.SESSION_TTL)
259
+ .expire(versionKey, SESSION_TTL)
264
260
  .exec();
265
261
  return updatedSession;
266
262
  }
@@ -489,27 +485,27 @@ async function releaseRefreshLock(sessionToken, requestId, lockVersion) {
489
485
  const lockKey = getRefreshLockKey(sessionToken);
490
486
  try {
491
487
  // Lua script for atomic lock validation and release
492
- const luaScript = `
493
- local lockKey = KEYS[1]
494
- local expectedRequestId = ARGV[1]
495
- local expectedVersion = ARGV[2]
496
-
497
- local lockData = redis.call('GET', lockKey)
498
- if not lockData then
499
- return 0 -- Lock doesn't exist
500
- end
501
-
502
- local lockInfo = cjson.decode(lockData)
503
- if lockInfo.acquiredBy == expectedRequestId then
504
- if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
505
- redis.call('DEL', lockKey)
506
- return 1 -- Successfully released
507
- else
508
- return -2 -- Version mismatch
509
- end
510
- else
511
- return -1 -- Wrong owner
512
- end
488
+ const luaScript = `
489
+ local lockKey = KEYS[1]
490
+ local expectedRequestId = ARGV[1]
491
+ local expectedVersion = ARGV[2]
492
+
493
+ local lockData = redis.call('GET', lockKey)
494
+ if not lockData then
495
+ return 0 -- Lock doesn't exist
496
+ end
497
+
498
+ local lockInfo = cjson.decode(lockData)
499
+ if lockInfo.acquiredBy == expectedRequestId then
500
+ if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
501
+ redis.call('DEL', lockKey)
502
+ return 1 -- Successfully released
503
+ else
504
+ return -2 -- Version mismatch
505
+ end
506
+ else
507
+ return -1 -- Wrong owner
508
+ end
513
509
  `;
514
510
  const result = await redis_1.default.eval(luaScript, 1, lockKey, requestId, lockVersion ? lockVersion.toString() : '');
515
511
  if (result === 1) {
@@ -213,25 +213,25 @@ async function performInitialization() {
213
213
  const connectionLine = isConnectionError
214
214
  ? 'β•‘ πŸ”Œ CONNECTION REFUSED - IDP appears to be down β•‘\nβ•‘ β•‘\n'
215
215
  : '';
216
- console.error(`
217
- ╔══════════════════════════════════════════════════════════════╗
218
- β•‘ ❌ FATAL: BETTER_AUTH_SECRET NOT AVAILABLE β•‘
219
- β•‘ β•‘
220
- β•‘ The app cannot start without a valid auth signing secret. β•‘
221
- β•‘ This should be fetched from IDP at startup. β•‘
222
- β•‘ β•‘
223
- ${connectionLine}β•‘ Possible causes: β•‘
224
- β•‘ β€’ IDP is not running or unreachable β•‘
225
- β•‘ β€’ CLIENT_ID is not registered in IDP β•‘
226
- β•‘ β€’ IDP_URL is incorrect β•‘
227
- β•‘ β€’ Network connectivity issue β•‘
228
- β•‘ β•‘
229
- β•‘ Current config: β•‘
230
- β•‘ β€’ IDP_URL: ${idpUrl}β•‘
231
- β•‘ β€’ CLIENT_ID: ${clientId}β•‘
232
- β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
233
-
234
- [STARTUP] Error: ${errorMsg}
216
+ console.error(`
217
+ ╔══════════════════════════════════════════════════════════════╗
218
+ β•‘ ❌ FATAL: BETTER_AUTH_SECRET NOT AVAILABLE β•‘
219
+ β•‘ β•‘
220
+ β•‘ The app cannot start without a valid auth signing secret. β•‘
221
+ β•‘ This should be fetched from IDP at startup. β•‘
222
+ β•‘ β•‘
223
+ ${connectionLine}β•‘ Possible causes: β•‘
224
+ β•‘ β€’ IDP is not running or unreachable β•‘
225
+ β•‘ β€’ CLIENT_ID is not registered in IDP β•‘
226
+ β•‘ β€’ IDP_URL is incorrect β•‘
227
+ β•‘ β€’ Network connectivity issue β•‘
228
+ β•‘ β•‘
229
+ β•‘ Current config: β•‘
230
+ β•‘ β€’ IDP_URL: ${idpUrl}β•‘
231
+ β•‘ β€’ CLIENT_ID: ${clientId}β•‘
232
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
233
+
234
+ [STARTUP] Error: ${errorMsg}
235
235
  `);
236
236
  // Re-throw so callers know initialization failed
237
237
  throw lastInitError;
@@ -31,8 +31,6 @@ export interface TokenError {
31
31
  error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
32
32
  message: string;
33
33
  terminal?: boolean;
34
- code?: string;
35
- resolution?: string;
36
34
  }
37
35
  export type EnsureFreshTokenResult = TokenResult | TokenError;
38
36
  /**
@@ -150,16 +150,7 @@ async function triggerRefresh(request, sessionToken, retryCount = 0) {
150
150
  // Don't retry - user must re-authenticate
151
151
  if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
152
152
  console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
153
- // WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
154
- // message in `error` and the code in `code`; carry both up so the consumer
155
- // can tell the user WHY (not just "session expired") and stop a futile loop.
156
- return {
157
- success: false,
158
- terminal: true,
159
- code: responseData.code,
160
- message: responseData.error,
161
- resolution: responseData.resolution,
162
- };
153
+ return { success: false, terminal: true, code: responseData.code };
163
154
  }
164
155
  // For other 401s, check if maybe session was refreshed by another request
165
156
  if (response.status === 401 && retryCount < maxRetries) {
@@ -327,21 +318,12 @@ async function ensureFreshToken(request) {
327
318
  if (!refreshResult.success) {
328
319
  // Check for terminal state - session cannot be recovered
329
320
  if (refreshResult.terminal) {
330
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login', {
331
- code: refreshResult.code,
332
- message: refreshResult.message,
333
- });
334
- // WO-1: keep `error` as the stable terminal category (existing consumers
335
- // branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
336
- // reason via code/message/resolution so the user learns WHY and a genuinely
337
- // dead session (e.g. unapproved account) doesn't loop login→bounce→login.
321
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
338
322
  return {
339
323
  success: false,
340
324
  error: 'SESSION_EXPIRED_NO_REFRESH',
341
- message: refreshResult.message || 'Session expired. Please sign in again.',
325
+ message: 'Session expired. Please sign in again.',
342
326
  terminal: true,
343
- code: refreshResult.code,
344
- resolution: refreshResult.resolution,
345
327
  };
346
328
  }
347
329
  console.warn('[TOKEN_LIFECYCLE] Refresh failed');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.1.11",
3
+ "version": "4.1.12",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,11 +10,12 @@
10
10
  "src"
11
11
  ],
12
12
  "scripts": {
13
- "build": "npx tsc --project tsconfig.json",
13
+ "build": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\" && npx tsc --project tsconfig.json",
14
14
  "prepublishOnly": "npm run build",
15
15
  "dev": "next dev",
16
16
  "dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
17
- "dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1"
17
+ "dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
18
+ "prepack": "npm run build"
18
19
  },
19
20
  "exports": {
20
21
  ".": {