@payez/next-mvp 4.11.0 → 4.12.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/api-handlers/account/recovery/forward.js +2 -1
  3. package/dist/api-handlers/account/recovery/initiate.js +2 -1
  4. package/dist/api-handlers/account/reset-password.js +2 -1
  5. package/dist/api-handlers/account/send-code.js +2 -1
  6. package/dist/api-handlers/account/update-phone.js +2 -1
  7. package/dist/api-handlers/account/validate-password.js +2 -1
  8. package/dist/api-handlers/account/verify-email.js +2 -1
  9. package/dist/api-handlers/account/verify-sms.js +2 -1
  10. package/dist/api-handlers/anon/preferences.js +3 -2
  11. package/dist/api-handlers/auth/credential-login-email-code.js +3 -2
  12. package/dist/api-handlers/auth/credential-login.js +6 -5
  13. package/dist/api-handlers/auth/refresh.js +2 -1
  14. package/dist/api-handlers/auth/signout.js +43 -6
  15. package/dist/api-handlers/mfa/backup-code-verify.js +2 -1
  16. package/dist/api-handlers/mfa/totp-verify.js +2 -1
  17. package/dist/api-handlers/session/refresh-viability.js +3 -2
  18. package/dist/api-handlers/session/viability.js +2 -1
  19. package/dist/auth/better-auth.js +7 -6
  20. package/dist/auth/idp-oauth.js +4 -2
  21. package/dist/auth/utils/idp-client.js +5 -4
  22. package/dist/auth/utils/token-utils.js +4 -3
  23. package/dist/config/logger.js +2 -1
  24. package/dist/config/logging-config.js +4 -3
  25. package/dist/config/vibe-log-transport.js +2 -1
  26. package/dist/lib/ensure-fresh-access-token.js +17 -11
  27. package/dist/lib/geolocation.js +5 -22
  28. package/dist/lib/idp-client-config.js +4 -3
  29. package/dist/lib/idp-fetch.d.ts +11 -0
  30. package/dist/lib/idp-fetch.js +3 -1
  31. package/dist/lib/idp-logout.d.ts +10 -0
  32. package/dist/lib/idp-logout.js +73 -1
  33. package/dist/lib/jwt-decode-client.js +2 -1
  34. package/dist/lib/jwt-decode.js +2 -1
  35. package/dist/lib/log-safe.d.ts +19 -0
  36. package/dist/lib/log-safe.js +32 -0
  37. package/dist/lib/redis.js +2 -1
  38. package/dist/lib/safe-error-response.d.ts +5 -1
  39. package/dist/lib/safe-error-response.js +7 -2
  40. package/dist/lib/session-store.js +2 -1
  41. package/dist/lib/site-logger.d.ts +2 -8
  42. package/dist/lib/site-logger.js +11 -5
  43. package/dist/lib/standardized-client-api.js +3 -2
  44. package/dist/lib/token-lifecycle.js +3 -2
  45. package/dist/middleware/create-middleware.js +2 -1
  46. package/dist/middleware/rbac-check.d.ts +0 -13
  47. package/dist/middleware/rbac-check.js +6 -5
  48. package/dist/routes/auth/logout.js +6 -2
  49. package/dist/routes/auth/settings.js +2 -1
  50. package/dist/server/auth-guard.js +4 -3
  51. package/dist/server/auth.js +5 -9
  52. package/dist/server/decode-session.js +3 -2
  53. package/dist/server/with-auth.js +2 -1
  54. package/package.json +1 -1
  55. package/src/api-handlers/account/recovery/forward.ts +2 -1
  56. package/src/api-handlers/account/recovery/initiate.ts +2 -1
  57. package/src/api-handlers/account/reset-password.ts +2 -1
  58. package/src/api-handlers/account/send-code.ts +2 -1
  59. package/src/api-handlers/account/update-phone.ts +2 -1
  60. package/src/api-handlers/account/validate-password.ts +2 -1
  61. package/src/api-handlers/account/verify-email.ts +2 -1
  62. package/src/api-handlers/account/verify-sms.ts +2 -1
  63. package/src/api-handlers/anon/preferences.ts +3 -2
  64. package/src/api-handlers/auth/credential-login-email-code.ts +3 -2
  65. package/src/api-handlers/auth/credential-login.ts +6 -5
  66. package/src/api-handlers/auth/refresh.ts +2 -1
  67. package/src/api-handlers/auth/signout.ts +54 -7
  68. package/src/api-handlers/mfa/backup-code-verify.ts +2 -1
  69. package/src/api-handlers/mfa/totp-verify.ts +2 -1
  70. package/src/api-handlers/session/refresh-viability.ts +3 -2
  71. package/src/api-handlers/session/viability.ts +2 -1
  72. package/src/auth/better-auth.ts +7 -6
  73. package/src/auth/idp-oauth.ts +7 -2
  74. package/src/auth/utils/idp-client.ts +5 -4
  75. package/src/auth/utils/token-utils.ts +4 -3
  76. package/src/config/logger.ts +2 -1
  77. package/src/config/logging-config.ts +4 -3
  78. package/src/config/vibe-log-transport.ts +2 -1
  79. package/src/lib/ensure-fresh-access-token.ts +17 -11
  80. package/src/lib/geolocation.ts +5 -4
  81. package/src/lib/idp-client-config.ts +4 -3
  82. package/src/lib/idp-fetch.ts +11 -1
  83. package/src/lib/idp-logout.ts +36 -1
  84. package/src/lib/jwt-decode-client.ts +3 -1
  85. package/src/lib/jwt-decode.ts +2 -1
  86. package/src/lib/log-safe.ts +30 -0
  87. package/src/lib/redis.ts +2 -1
  88. package/src/lib/safe-error-response.ts +7 -2
  89. package/src/lib/session-store.ts +2 -1
  90. package/src/lib/site-logger.ts +13 -7
  91. package/src/lib/standardized-client-api.ts +3 -2
  92. package/src/lib/token-lifecycle.ts +3 -2
  93. package/src/middleware/create-middleware.ts +2 -1
  94. package/src/middleware/rbac-check.ts +2 -1
  95. package/src/routes/auth/logout.ts +9 -2
  96. package/src/routes/auth/settings.ts +2 -1
  97. package/src/server/auth-guard.ts +4 -3
  98. package/src/server/auth.ts +5 -10
  99. package/src/server/decode-session.ts +3 -2
  100. package/src/server/with-auth.ts +2 -1
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.redisLogger = exports.tokenRefreshLogger = exports.tokenSyncLogger = exports.tokenLogger = exports.circuitBreakerLogger = exports.apiLogger = exports.idpLogger = exports.authLogger = exports.logger = exports.log = void 0;
4
4
  const logging_config_1 = require("./logging-config");
5
5
  const vibe_log_transport_1 = require("./vibe-log-transport");
6
+ const log_safe_1 = require("../lib/log-safe");
6
7
  let loggingConfig = (0, logging_config_1.getLoggingConfigSync)();
7
8
  (0, logging_config_1.startConfigWatching)();
8
9
  const isEdgeRuntime = () => {
@@ -48,7 +49,7 @@ const createLogger = () => {
48
49
  return winston.createLogger({ level: loggingConfig.logLevel, levels: loggingConfig.levels, format: winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json()), transports, exitOnError: false });
49
50
  }
50
51
  catch (error) {
51
- console.warn('Failed to initialize Winston logger, falling back to EdgeLogger:', error);
52
+ console.warn('Failed to initialize Winston logger, falling back to EdgeLogger:', (0, log_safe_1.errForLog)(error));
52
53
  return new EdgeLogger();
53
54
  }
54
55
  };
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.removeConfigWatcher = exports.addConfigWatcher = exports.stopConfigWatching = exports.startConfigWatching = exports.getLoggingConfigSync = exports.getLoggingConfig = exports.loggingConfigManager = void 0;
4
4
  const fs_1 = require("fs");
5
5
  const fs_2 = require("fs");
6
+ const log_safe_1 = require("../lib/log-safe");
6
7
  const isEdgeRuntime = typeof globalThis.EdgeRuntime !== 'undefined' || process.env.NEXT_RUNTIME === 'edge';
7
8
  class LoggingConfigManager {
8
9
  config = null;
@@ -39,7 +40,7 @@ class LoggingConfigManager {
39
40
  return config;
40
41
  }
41
42
  catch (error) {
42
- console.warn(`Failed to load logging config from ${this.configPath}:`, error);
43
+ console.warn(`Failed to load logging config from ${this.configPath}:`, (0, log_safe_1.errForLog)(error));
43
44
  return this.getDefaultConfig();
44
45
  }
45
46
  }
@@ -61,7 +62,7 @@ class LoggingConfigManager {
61
62
  } });
62
63
  }
63
64
  catch (error) {
64
- console.warn('Failed to start watching logging config file:', error);
65
+ console.warn('Failed to start watching logging config file:', (0, log_safe_1.errForLog)(error));
65
66
  } }
66
67
  stopWatching() { if (this.isEdgeRuntime || this.isBrowser) {
67
68
  return;
@@ -75,7 +76,7 @@ class LoggingConfigManager {
75
76
  cb(config);
76
77
  }
77
78
  catch (error) {
78
- console.error('Error in logging config watcher:', error);
79
+ console.error('Error in logging config watcher:', (0, log_safe_1.errForLog)(error));
79
80
  } }); }
80
81
  validateAndMergeConfig(rawConfig) {
81
82
  const defaults = this.getDefaultConfig();
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.VibeLogTransport = void 0;
16
16
  exports.createVibeLogTransport = createVibeLogTransport;
17
17
  const redis_1 = require("../lib/redis");
18
+ const log_safe_1 = require("../lib/log-safe");
18
19
  // Dynamic import — winston is a peerDependency and may not be installed.
19
20
  // We resolve the base class lazily so builds don't break without it.
20
21
  let TransportBase;
@@ -144,7 +145,7 @@ class VibeLogTransport extends TransportBase {
144
145
  this.batch = [...entries, ...this.batch].slice(0, 100);
145
146
  }
146
147
  // Log to console as fallback
147
- console.error('[VibeLogTransport] Failed to flush logs:', err);
148
+ console.error('[VibeLogTransport] Failed to flush logs:', (0, log_safe_1.errForLog)(err));
148
149
  }
149
150
  finally {
150
151
  this.isFlushing = false;
@@ -25,6 +25,7 @@ const refresh_rotation_1 = require("./refresh-rotation");
25
25
  const refresh_window_1 = require("./refresh-window");
26
26
  const safe_trace_id_1 = require("./safe-trace-id");
27
27
  const refresh_in_doubt_1 = require("./refresh-in-doubt");
28
+ const log_safe_1 = require("./log-safe");
28
29
  /**
29
30
  * The refresh envelope rule (BAPert 76455 (B)): `success` is a boolean; `success:true` needs a `data` object holding a string `access_token`;
30
31
  * `success:false` needs `error.code` as a string; `meta`, `request_id` and `time_stamp` are optional; a non-2xx can not say success:true.
@@ -256,17 +257,18 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
256
257
  }
257
258
  catch { /* fall through */ }
258
259
  }
259
- const isOAuthSession = !!session.oauthProvider;
260
- if (authMethods.length === 0 && isOAuthSession) {
261
- authMethods = ['pwd', 'mfa'];
262
- }
260
+ // PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
261
+ // (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
262
+ // a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
263
+ // could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
264
+ // 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
265
+ // the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
266
+ // re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
267
+ // (e6e3b6c3, e3b3bb12) and was never merged.
263
268
  const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
264
269
  session.mfaMethod ||
265
- (isOAuthSession ? 'oauth' : null);
266
- let acrValue = String(session.authenticationLevel ?? '1');
267
- if (isOAuthSession && session.mfaVerified && acrValue === '1') {
268
- acrValue = '2';
269
- }
270
+ null;
271
+ const acrValue = String(session.authenticationLevel ?? '1');
270
272
  // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
271
273
  // `session` was read before we acquired the lock; a concurrent refresh that
272
274
  // completed while we waited rotates the token, so the pre-lock value is stale
@@ -299,7 +301,11 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
299
301
  amr: authMethods,
300
302
  acr: acrValue,
301
303
  };
302
- if (session.mfaVerified)
304
+ // PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
305
+ // bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
306
+ // poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
307
+ const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
308
+ if (session.mfaVerified && methodsAttestSecondFactor)
303
309
  body.two_factor_verified = true;
304
310
  if (twoFactorMethod)
305
311
  body.two_factor_method = twoFactorMethod;
@@ -318,7 +324,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
318
324
  await (0, session_store_1.updateSession)(sessionToken, (0, refresh_in_doubt_1.inDoubtPatch)(sentRefreshToken));
319
325
  }
320
326
  catch (markError) {
321
- console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: markError instanceof Error ? markError.message : String(markError) });
327
+ console.error('[ENSURE-FRESH] could not record the in-doubt marker', { error: (0, log_safe_1.errForLog)(markError) });
322
328
  }
323
329
  return {
324
330
  ok: false,
@@ -1,22 +1,4 @@
1
1
  "use strict";
2
- /**
3
- * =============================================================================
4
- * GEOLOCATION UTILITY
5
- * =============================================================================
6
- *
7
- * Server-side utility for resolving IP addresses to geographic locations.
8
- * Uses ip-api.com (free tier: 45 requests/minute).
9
- *
10
- * USAGE:
11
- * ------
12
- * import { getLocationFromIP, extractClientIP } from '@payez/next-mvp/lib/geolocation';
13
- *
14
- * const ip = extractClientIP(request.headers);
15
- * const location = await getLocationFromIP(ip);
16
- * console.log(location?.city); // 'Mountain View'
17
- *
18
- * =============================================================================
19
- */
20
2
  Object.defineProperty(exports, "__esModule", { value: true });
21
3
  exports.isPrivateIP = isPrivateIP;
22
4
  exports.getPublicIP = getPublicIP;
@@ -25,6 +7,7 @@ exports.getLocationFromIP = getLocationFromIP;
25
7
  exports.formatLocation = formatLocation;
26
8
  exports.getCountryFlag = getCountryFlag;
27
9
  exports.clearGeoCache = clearGeoCache;
10
+ const log_safe_1 = require("./log-safe");
28
11
  // -----------------------------------------------------------------------------
29
12
  // CACHE CONFIGURATION
30
13
  // -----------------------------------------------------------------------------
@@ -90,7 +73,7 @@ async function getPublicIP() {
90
73
  // Validate it looks like an IP
91
74
  if (ip && /^[\d.]+$/.test(ip) || /^[a-f0-9:]+$/i.test(ip)) {
92
75
  cachedPublicIP = { ip, timestamp: Date.now() };
93
- console.log(`[geolocation] Public IP resolved: ${ip}`);
76
+ console.log("[geolocation] Public IP resolved");
94
77
  return ip;
95
78
  }
96
79
  }
@@ -147,7 +130,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
147
130
  if (resolvePublicIP) {
148
131
  const publicIP = await getPublicIP();
149
132
  if (publicIP && !isPrivateIP(publicIP)) {
150
- console.log(`[geolocation] Private IP ${ip} -> using public IP ${publicIP}`);
133
+ console.log("[geolocation] Private IP -> using the resolved public IP");
151
134
  // Recursively call with public IP (but don't resolve again to prevent loops)
152
135
  return getLocationFromIP(publicIP, false);
153
136
  }
@@ -176,7 +159,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
176
159
  }
177
160
  const data = await response.json();
178
161
  if (data.status !== 'success') {
179
- console.warn(`[geolocation] Lookup failed for ${ip}: ${data.message}`);
162
+ console.warn("[geolocation] Lookup failed (upstream refused)");
180
163
  // Cache the failure to avoid repeated lookups
181
164
  geoCache.set(ip, { location: null, timestamp: Date.now() });
182
165
  return null;
@@ -196,7 +179,7 @@ async function getLocationFromIP(ip, resolvePublicIP = true) {
196
179
  return location;
197
180
  }
198
181
  catch (error) {
199
- console.error(`[geolocation] Error looking up ${ip}:`, error);
182
+ console.error("[geolocation] lookup failed:", (0, log_safe_1.errForLog)(error)); // no IP on a routine line (BAPert 76553 Q1)
200
183
  return null;
201
184
  }
202
185
  }
@@ -82,6 +82,7 @@ exports.getEnabledProviders = getEnabledProviders;
82
82
  require("server-only");
83
83
  const crypto_1 = require("crypto");
84
84
  const never_in_response_1 = require("./never-in-response");
85
+ const log_safe_1 = require("./log-safe");
85
86
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
86
87
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
87
88
  // touches this file - including instrumentation.ts. Next compiles the instrumentation
@@ -276,7 +277,7 @@ async function getConfigFromRedis() {
276
277
  return parsed.config;
277
278
  }
278
279
  catch (error) {
279
- console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
280
+ console.warn('[IDP_CONFIG] Failed to read from Redis cache:', (0, log_safe_1.errForLog)(error));
280
281
  return null;
281
282
  }
282
283
  }
@@ -293,7 +294,7 @@ async function setConfigInRedis(config) {
293
294
  await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
294
295
  }
295
296
  catch (error) {
296
- console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
297
+ console.warn('[IDP_CONFIG] Failed to write to Redis cache:', (0, log_safe_1.errForLog)(error));
297
298
  }
298
299
  }
299
300
  // ============================================================================
@@ -529,7 +530,7 @@ async function clearConfigRedisCache() {
529
530
  console.log('[IDP_CONFIG] Redis cache cleared:', key);
530
531
  }
531
532
  catch (error) {
532
- console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
533
+ console.warn('[IDP_CONFIG] Failed to clear Redis cache:', (0, log_safe_1.errForLog)(error));
533
534
  }
534
535
  }
535
536
  /**
@@ -35,5 +35,16 @@ export interface IdpFetchOptions {
35
35
  * `skipped: true` without a request; null on the retry leaves the first answer standing. Absent: `init` is used as before.
36
36
  */
37
37
  initFor?: (sessionToken: string) => Promise<RequestInit | null>;
38
+ /**
39
+ * 4.12: the refresh that follows a 401/403 is done by THIS function instead of the HTTP self-call to /api/auth/refresh. Sign-out supplies one that
40
+ * calls `ensureFreshAccessToken` in-process with a window that cannot be "fresh": a logout 401 with a locally-fresh token usually means Core
41
+ * invalidated the token (password change), and only the IdP's answer to a real refresh (401 + the discard marker = the grant is dead; success =
42
+ * the grant is live) can say which. No header on the public refresh route, so nothing a browser sends can force a refresh (BAPert 77022).
43
+ * Returns the status the refresh route would have answered. Absent: `internalRefresh` as before.
44
+ */
45
+ refreshOnAuthFailure?: (sessionToken: string) => Promise<{
46
+ ok: boolean;
47
+ status: number;
48
+ }>;
38
49
  }
39
50
  export declare function idpFetchJSON<T = any>(req: NextRequest, targetUrl: string, init?: RequestInit, opts?: IdpFetchOptions): Promise<IdpFetchResult<T>>;
@@ -180,7 +180,9 @@ async function idpFetchJSON(req, targetUrl, init = {}, opts = {}) {
180
180
  attemptedRefresh = true;
181
181
  try {
182
182
  // Use centralized internal API helper for server-to-server calls
183
- const rf = await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
183
+ const rf = opts.refreshOnAuthFailure
184
+ ? await opts.refreshOnAuthFailure(sessionToken)
185
+ : await (0, internal_api_1.internalRefresh)(req.headers.get('cookie') || '', sessionToken);
184
186
  refreshStatus = rf.status;
185
187
  if (rf.ok) {
186
188
  const fresh = await (0, session_store_1.getSession)(sessionToken);
@@ -21,4 +21,14 @@ import type { NextRequest } from 'next/server';
21
21
  * refresh token, and a body built earlier would name the old one (NightHawk 76288).
22
22
  */
23
23
  export type IdpLogoutOutcome = 'revoked' | 'nothing-to-revoke' | 'grant-already-dead';
24
+ /**
25
+ * The revoke could not be confirmed. `grantLive` is true ONLY when the IdP itself showed the grant is alive: the forced refresh SUCCEEDED (it issued
26
+ * new tokens) and the logout still did not end it. Every other unknown (a transport error, a 5xx, a refresh that threw or answered something
27
+ * else) leaves it false: the grant may be live or dead, and the log must not claim either (BAPert 77013). The closed `code` is what errForLog prints.
28
+ */
29
+ export declare class IdpLogoutUnconfirmedError extends Error {
30
+ readonly grantLive: boolean;
31
+ readonly code = "IDP_LOGOUT_UNCONFIRMED";
32
+ constructor(status: number, grantLive: boolean);
33
+ }
24
34
  export declare function revokeSessionAtIdp(req: NextRequest): Promise<IdpLogoutOutcome>;
@@ -1,12 +1,64 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IdpLogoutUnconfirmedError = void 0;
3
37
  exports.revokeSessionAtIdp = revokeSessionAtIdp;
4
38
  const idp_fetch_1 = require("./idp-fetch");
5
39
  const session_store_1 = require("./session-store");
6
40
  const env_1 = require("../config/env");
41
+ /**
42
+ * The revoke could not be confirmed. `grantLive` is true ONLY when the IdP itself showed the grant is alive: the forced refresh SUCCEEDED (it issued
43
+ * new tokens) and the logout still did not end it. Every other unknown (a transport error, a 5xx, a refresh that threw or answered something
44
+ * else) leaves it false: the grant may be live or dead, and the log must not claim either (BAPert 77013). The closed `code` is what errForLog prints.
45
+ */
46
+ class IdpLogoutUnconfirmedError extends Error {
47
+ grantLive;
48
+ code = 'IDP_LOGOUT_UNCONFIRMED';
49
+ constructor(status, grantLive) {
50
+ super(`IdP logout answered ${status}`);
51
+ this.grantLive = grantLive;
52
+ this.name = 'IdpLogoutUnconfirmedError';
53
+ }
54
+ }
55
+ exports.IdpLogoutUnconfirmedError = IdpLogoutUnconfirmedError;
7
56
  async function revokeSessionAtIdp(req) {
8
57
  let resolvedSession;
9
58
  const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/logout`, {}, {
59
+ // A logout 401 with a locally-fresh token must reach a REAL refresh, so the IdP's answer can prove the grant dead (BAPert 77013). Done
60
+ // in-process, not through the public refresh route, so no request header can force it (NightHawk 77021, BAPert 77022).
61
+ refreshOnAuthFailure: forceRefreshAtIdp,
10
62
  initFor: async (token) => {
11
63
  resolvedSession = token;
12
64
  let session = await (0, session_store_1.getSession)(token);
@@ -28,7 +80,27 @@ async function revokeSessionAtIdp(req) {
28
80
  return 'revoked';
29
81
  if (result.attemptedRefresh && result.refreshStatus === 401 && resolvedSession && (await grantWasDiscarded(resolvedSession)))
30
82
  return 'grant-already-dead';
31
- throw new Error(`IdP logout answered ${result.status}`);
83
+ const refreshSucceeded = result.attemptedRefresh && typeof result.refreshStatus === 'number' && result.refreshStatus >= 200 && result.refreshStatus < 300;
84
+ throw new IdpLogoutUnconfirmedError(result.status, refreshSucceeded);
85
+ }
86
+ /**
87
+ * Refresh the session NOW, ignoring "the stored access token looks fresh", by calling `ensureFreshAccessToken` in this process (the one
88
+ * implementation that talks to the IdP, holds the lock and writes the discard marker). Config is read the way `oauth/resume` reads it
89
+ * (IDP_URL, CLIENT_ID, the default refresh endpoint). It answers with the status the refresh route would have: 200 on success, 401 for
90
+ * NO_SESSION / NO_REFRESH_TOKEN, 409 for a lost lock, otherwise E's own status (a discarded IdP 401 is 401 with the marker written).
91
+ * Missing config or a throw is an UNKNOWN outcome: the caller treats a thrown refresh as `refreshStatus: null`.
92
+ */
93
+ async function forceRefreshAtIdp(sessionToken) {
94
+ const idpBaseUrl = process.env.IDP_URL;
95
+ const clientId = process.env.CLIENT_ID;
96
+ if (!idpBaseUrl || !clientId)
97
+ throw new Error('IDP_URL and CLIENT_ID are required to prove a grant dead');
98
+ const { ensureFreshAccessToken } = await Promise.resolve().then(() => __importStar(require('./ensure-fresh-access-token')));
99
+ const r = await ensureFreshAccessToken(sessionToken, { idpBaseUrl, clientId }, { requireRefreshToken: true, safetyWindowMs: Number.MAX_SAFE_INTEGER });
100
+ if (r.ok)
101
+ return { ok: true, status: 200 };
102
+ const status = r.code === 'NO_SESSION' || r.code === 'NO_REFRESH_TOKEN' ? 401 : r.code === 'CONFLICT' ? 409 : r.status;
103
+ return { ok: false, status };
32
104
  }
33
105
  /**
34
106
  * A refresh 401 is not by itself "the grant is gone" (NightHawk 76295, BAPert 76297): refresh.ts also answers 401 for 'no session', for
@@ -5,6 +5,7 @@
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.jwtDecode = jwtDecode;
8
+ const log_safe_1 = require("./log-safe");
8
9
  // Decode base64url
9
10
  function base64urlDecode(base64url) {
10
11
  try {
@@ -40,7 +41,7 @@ function jwtDecode(token) {
40
41
  return parsedPayload;
41
42
  }
42
43
  catch (e) {
43
- console.error('[JWT] Decode failed:', e instanceof Error ? e.message : 'Unknown error');
44
+ console.error('[JWT] Decode failed:', (0, log_safe_1.errForLog)(e));
44
45
  return null;
45
46
  }
46
47
  }
@@ -4,6 +4,7 @@ exports.jwtDecode = jwtDecode;
4
4
  exports.decodeJwtHeader = decodeJwtHeader;
5
5
  exports.extractKidFromToken = extractKidFromToken;
6
6
  const jwt_decode_1 = require("jwt-decode");
7
+ const log_safe_1 = require("./log-safe");
7
8
  /**
8
9
  * Decode JWT payload (standard claims).
9
10
  * This is a thin wrapper around jwt-decode library.
@@ -40,7 +41,7 @@ function decodeJwtHeader(token) {
40
41
  return JSON.parse(headerJson);
41
42
  }
42
43
  catch (error) {
43
- console.error('[JWT_DECODE] Failed to decode JWT header:', error);
44
+ console.error('[JWT_DECODE] Failed to decode JWT header:', (0, log_safe_1.errForLog)(error));
44
45
  return null;
45
46
  }
46
47
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * log-safe: the ONLY way a caught error or an upstream `code` goes into a server log (PII-in-logs census, BAPert 76553 group 1; ported from the
3
+ * portal's api-error-response.ts, BAPert 76470).
4
+ *
5
+ * Server logs persist and are read beyond the person who made the request. An error's `message`, `stack`, `cause` and `details` can carry an
6
+ * email, a phone number, a code, a token, a SQL fragment or a host: an IdP error's message is the upstream's own free text, and V8's JSON parse
7
+ * error quotes its input. So a log line gets the error's NAME and a closed STATUS / CODE, nothing else. Every field is matched against a closed
8
+ * shape; anything that does not match is dropped, not truncated.
9
+ *
10
+ * No imports on purpose: this is a leaf, loaded by modules that must not pull in anything else.
11
+ */
12
+ /** An upstream `code` is loggable only if it is a closed token (UPPER_SNAKE). Anything else, including free text, becomes null. */
13
+ export declare function closedCodeForLog(code: unknown): string | null;
14
+ /**
15
+ * Describe a caught error for a log line: `error=<Name> [status=<n>] [code=<CLOSED_CODE>]`.
16
+ * It NEVER carries `message`, `stack`, `cause` or `details`. A name that is not a short identifier (a hostile or free-text `name`) becomes
17
+ * `NonError`; a status outside 100-599 is dropped; a code outside the closed token shape is dropped.
18
+ */
19
+ export declare function errForLog(err: unknown): string;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ /**
3
+ * log-safe: the ONLY way a caught error or an upstream `code` goes into a server log (PII-in-logs census, BAPert 76553 group 1; ported from the
4
+ * portal's api-error-response.ts, BAPert 76470).
5
+ *
6
+ * Server logs persist and are read beyond the person who made the request. An error's `message`, `stack`, `cause` and `details` can carry an
7
+ * email, a phone number, a code, a token, a SQL fragment or a host: an IdP error's message is the upstream's own free text, and V8's JSON parse
8
+ * error quotes its input. So a log line gets the error's NAME and a closed STATUS / CODE, nothing else. Every field is matched against a closed
9
+ * shape; anything that does not match is dropped, not truncated.
10
+ *
11
+ * No imports on purpose: this is a leaf, loaded by modules that must not pull in anything else.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.closedCodeForLog = closedCodeForLog;
15
+ exports.errForLog = errForLog;
16
+ /** An upstream `code` is loggable only if it is a closed token (UPPER_SNAKE). Anything else, including free text, becomes null. */
17
+ function closedCodeForLog(code) {
18
+ return typeof code === 'string' && /^[A-Z0-9_]{1,64}$/.test(code) ? code : null;
19
+ }
20
+ /**
21
+ * Describe a caught error for a log line: `error=<Name> [status=<n>] [code=<CLOSED_CODE>]`.
22
+ * It NEVER carries `message`, `stack`, `cause` or `details`. A name that is not a short identifier (a hostile or free-text `name`) becomes
23
+ * `NonError`; a status outside 100-599 is dropped; a code outside the closed token shape is dropped.
24
+ */
25
+ function errForLog(err) {
26
+ const e = (err !== null && typeof err === 'object' ? err : {});
27
+ const name = typeof e.name === 'string' && /^[A-Za-z0-9_]{1,48}$/.test(e.name) ? e.name : 'NonError';
28
+ const status = typeof e.status === 'number' && Number.isInteger(e.status) && e.status >= 100 && e.status <= 599 ? ` status=${e.status}` : '';
29
+ const closed = closedCodeForLog(e.code);
30
+ const code = closed ? ` code=${closed}` : '';
31
+ return `error=${name}${status}${code}`;
32
+ }
package/dist/lib/redis.js CHANGED
@@ -7,6 +7,7 @@ exports.redis = void 0;
7
7
  exports.getRedis = getRedis;
8
8
  // E:\Repos\PayEz-Next-MVP\packages\next-mvp\src\lib\redis.ts
9
9
  const ioredis_1 = __importDefault(require("ioredis"));
10
+ const log_safe_1 = require("./log-safe");
10
11
  let client = null;
11
12
  // ── 183496: fail FAST and NAMED, never a 20s spinner ───────────────────────
12
13
  // The DEFAULTS were the defect (Nextpert-Scout's killable-proxy measurement,
@@ -75,7 +76,7 @@ function createClient() {
75
76
  // unhandled EventEmitter error, which can take the whole PROCESS down -
76
77
  // a Redis blip becoming a dead server is worse than the spinner was.
77
78
  instance.on('error', (err) => {
78
- console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${err instanceof Error ? err.message : String(err)}`);
79
+ console.error(`[redis] PATH FAILURE ${new Date().toISOString()}: ${(0, log_safe_1.errForLog)(err)}`);
79
80
  });
80
81
  // 185121 line 3: the 16:05 event left no named trigger because ONLY 'error'
81
82
  // was listened for. A reconnect or a clean end is silent in the artifact,
@@ -19,7 +19,11 @@ export interface IdpErrorProjection {
19
19
  resolution?: string;
20
20
  request_id?: string;
21
21
  }
22
- /** Log an exception with a fresh correlationId and return the id for the response body. */
22
+ /**
23
+ * Log an exception with a fresh correlationId and return the id for the response body.
24
+ * The log line carries the error's NAME and closed code/status only (errForLog), never its message, stack or cause: those can hold an
25
+ * email, a phone number, a token or a quoted request body (PII-in-logs, BAPert 76553 group 1). The correlationId still ties the response to the line.
26
+ */
23
27
  export declare function logExceptionWithCorrelation(tag: string, err: unknown): string;
24
28
  /** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
25
29
  export declare function projectIdpError(json: unknown): IdpErrorProjection;
@@ -23,11 +23,16 @@ exports.projectResetTokenInvalid = projectResetTokenInvalid;
23
23
  exports.projectPasswordPolicyRules = projectPasswordPolicyRules;
24
24
  exports.projectPasswordBreached = projectPasswordBreached;
25
25
  const password_rules_1 = require("./password-rules");
26
+ const log_safe_1 = require("./log-safe");
26
27
  const str = (v) => (typeof v === 'string' ? v : undefined);
27
- /** Log an exception with a fresh correlationId and return the id for the response body. */
28
+ /**
29
+ * Log an exception with a fresh correlationId and return the id for the response body.
30
+ * The log line carries the error's NAME and closed code/status only (errForLog), never its message, stack or cause: those can hold an
31
+ * email, a phone number, a token or a quoted request body (PII-in-logs, BAPert 76553 group 1). The correlationId still ties the response to the line.
32
+ */
28
33
  function logExceptionWithCorrelation(tag, err) {
29
34
  const correlationId = globalThis.crypto.randomUUID();
30
- console.error(tag, { correlationId, error: err });
35
+ console.error(tag, { correlationId, error: (0, log_safe_1.errForLog)(err) });
31
36
  return correlationId;
32
37
  }
33
38
  /** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
@@ -44,6 +44,7 @@ const app_slug_1 = require("./app-slug");
44
44
  const token_utils_1 = require("../auth/utils/token-utils");
45
45
  const token_log_id_1 = require("./token-log-id");
46
46
  const refresh_in_doubt_1 = require("./refresh-in-doubt");
47
+ const log_safe_1 = require("./log-safe");
47
48
  // Use app-slug prefixes for multi-app isolation
48
49
  const getSessionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}${token}`;
49
50
  const getRefreshLockKey = (token) => `${(0, app_slug_1.getRefreshLockPrefix)()}${token}`;
@@ -79,7 +80,7 @@ async function createSession(data) {
79
80
  .exec();
80
81
  }
81
82
  catch (error) {
82
- console.error('[SESSION-STORE] Failed to create session:', error);
83
+ console.error('[SESSION-STORE] Failed to create session:', (0, log_safe_1.errForLog)(error));
83
84
  throw error;
84
85
  }
85
86
  return sessionToken;
@@ -54,7 +54,6 @@ export declare const siteEvents: {
54
54
  }) => void;
55
55
  loginFailed: (opts: {
56
56
  reason: string;
57
- email?: string;
58
57
  url?: string;
59
58
  user_agent?: string;
60
59
  ip_address?: string;
@@ -106,9 +105,7 @@ export declare const siteEvents: {
106
105
  ip_address?: string;
107
106
  }) => void;
108
107
  error: (opts: {
109
- message: string;
110
- error?: string;
111
- stack?: string;
108
+ code: string;
112
109
  user_id?: string | number;
113
110
  url?: string;
114
111
  user_agent?: string;
@@ -139,7 +136,6 @@ declare const _default: {
139
136
  }) => void;
140
137
  loginFailed: (opts: {
141
138
  reason: string;
142
- email?: string;
143
139
  url?: string;
144
140
  user_agent?: string;
145
141
  ip_address?: string;
@@ -191,9 +187,7 @@ declare const _default: {
191
187
  ip_address?: string;
192
188
  }) => void;
193
189
  error: (opts: {
194
- message: string;
195
- error?: string;
196
- stack?: string;
190
+ code: string;
197
191
  user_id?: string | number;
198
192
  url?: string;
199
193
  user_agent?: string;
@@ -16,6 +16,7 @@ exports.configureSiteLogger = configureSiteLogger;
16
16
  exports.logSiteEvent = logSiteEvent;
17
17
  exports.getClientIp = getClientIp;
18
18
  const redis_1 = require("./redis");
19
+ const log_safe_1 = require("./log-safe");
19
20
  // Redis key for site logs (separate from data_logs)
20
21
  const REDIS_SITE_LOG_KEY = 'vibe:site-logs:pending';
21
22
  const REDIS_LOG_TTL = 7 * 24 * 60 * 60; // 1 week
@@ -53,12 +54,12 @@ async function logSiteEvent(entry) {
53
54
  console.log('[site-logger] Redis push success, queue length:', result);
54
55
  redis.expire(REDIS_SITE_LOG_KEY, REDIS_LOG_TTL).catch(() => { });
55
56
  }).catch(err => {
56
- console.error('[site-logger] Redis push failed:', err.message);
57
+ console.error('[site-logger] Redis push failed:', (0, log_safe_1.errForLog)(err));
57
58
  });
58
59
  }
59
60
  catch (error) {
60
61
  // Fail silently - logging should never break the app
61
- console.error('[site-logger] Failed to log event:', error);
62
+ console.error('[site-logger] Failed to log event:', (0, log_safe_1.errForLog)(error));
62
63
  }
63
64
  }
64
65
  /**
@@ -94,12 +95,14 @@ exports.siteEvents = {
94
95
  ip_address: opts.ip_address,
95
96
  });
96
97
  },
98
+ // BREAKING (PII-in-logs group 3b, BAPert 76553 Q2): no `email` parameter. A failed login is told apart by a CLOSED reason code (UPPER_SNAKE),
99
+ // never by the address that was tried; anything that is not a closed code is stored as UNSPECIFIED. A caller that still passes `email` fails to compile.
97
100
  loginFailed: (opts) => {
98
101
  logSiteEvent({
99
102
  level: 'warn',
100
103
  category: 'auth',
101
104
  message: 'Login failed',
102
- context: { reason: opts.reason, email: opts.email },
105
+ context: { reason: (0, log_safe_1.closedCodeForLog)(opts.reason) ?? 'UNSPECIFIED' },
103
106
  url: opts.url || '/auth/login',
104
107
  user_agent: opts.user_agent,
105
108
  ip_address: opts.ip_address,
@@ -181,12 +184,15 @@ exports.siteEvents = {
181
184
  ip_address: opts.ip_address,
182
185
  });
183
186
  },
187
+ // BREAKING (PII-in-logs group 3b, BAPert 76553 Q2): takes a CLOSED code, not caller text, an error string or a stack. The stored message is the fixed
188
+ // sentence 'Application error'; the code (UPPER_SNAKE, else UNSPECIFIED) goes in context. A caller that still passes `message`, `error` or `stack`
189
+ // fails to compile.
184
190
  error: (opts) => {
185
191
  logSiteEvent({
186
192
  level: 'error',
187
193
  category: 'error',
188
- message: opts.message,
189
- context: { error: opts.error, stack: opts.stack },
194
+ message: 'Application error',
195
+ context: { code: (0, log_safe_1.closedCodeForLog)(opts.code) ?? 'UNSPECIFIED' },
190
196
  user_id: opts.user_id,
191
197
  url: opts.url,
192
198
  user_agent: opts.user_agent,