@payez/next-mvp 4.10.0 → 4.12.0

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 (123) hide show
  1. package/CHANGELOG.md +44 -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.d.ts +17 -58
  14. package/dist/api-handlers/auth/refresh.js +73 -773
  15. package/dist/api-handlers/auth/signout.js +43 -6
  16. package/dist/api-handlers/mfa/backup-code-verify.js +2 -1
  17. package/dist/api-handlers/mfa/totp-verify.js +2 -1
  18. package/dist/api-handlers/session/refresh-viability.js +3 -2
  19. package/dist/api-handlers/session/viability.js +2 -1
  20. package/dist/auth/better-auth.js +7 -6
  21. package/dist/auth/idp-oauth.js +4 -2
  22. package/dist/auth/utils/idp-client.js +5 -4
  23. package/dist/auth/utils/token-utils.d.ts +1 -1
  24. package/dist/auth/utils/token-utils.js +7 -5
  25. package/dist/config/logger.js +2 -1
  26. package/dist/config/logging-config.js +4 -3
  27. package/dist/config/vibe-log-transport.js +2 -1
  28. package/dist/lib/api-handler.js +4 -3
  29. package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
  30. package/dist/lib/ensure-fresh-access-token.js +195 -28
  31. package/dist/lib/geolocation.js +5 -22
  32. package/dist/lib/idp-client-config.js +4 -3
  33. package/dist/lib/idp-fetch.d.ts +11 -0
  34. package/dist/lib/idp-fetch.js +6 -3
  35. package/dist/lib/idp-logout.d.ts +10 -0
  36. package/dist/lib/idp-logout.js +73 -1
  37. package/dist/lib/jwt-decode-client.js +2 -1
  38. package/dist/lib/jwt-decode.js +2 -1
  39. package/dist/lib/log-safe.d.ts +19 -0
  40. package/dist/lib/log-safe.js +32 -0
  41. package/dist/lib/redis.js +2 -1
  42. package/dist/lib/refresh-in-doubt.d.ts +31 -0
  43. package/dist/lib/refresh-in-doubt.js +81 -0
  44. package/dist/lib/refresh-token-validator.js +2 -1
  45. package/dist/lib/refresh-window.d.ts +16 -0
  46. package/dist/lib/refresh-window.js +19 -0
  47. package/dist/lib/safe-error-response.d.ts +5 -1
  48. package/dist/lib/safe-error-response.js +7 -2
  49. package/dist/lib/safe-trace-id.d.ts +11 -0
  50. package/dist/lib/safe-trace-id.js +23 -0
  51. package/dist/lib/session-store.js +16 -5
  52. package/dist/lib/site-logger.d.ts +2 -8
  53. package/dist/lib/site-logger.js +11 -5
  54. package/dist/lib/standardized-client-api.js +3 -2
  55. package/dist/lib/token-lifecycle.js +6 -4
  56. package/dist/middleware/create-middleware.js +2 -1
  57. package/dist/middleware/rbac-check.d.ts +0 -13
  58. package/dist/middleware/rbac-check.js +6 -5
  59. package/dist/models/SessionModel.d.ts +7 -0
  60. package/dist/routes/auth/logout.js +6 -2
  61. package/dist/routes/auth/refresh.d.ts +1 -15
  62. package/dist/routes/auth/settings.js +2 -1
  63. package/dist/server/auth-guard.js +4 -3
  64. package/dist/server/auth.d.ts +1 -1
  65. package/dist/server/auth.js +5 -9
  66. package/dist/server/decode-session.js +3 -2
  67. package/dist/server/with-auth.js +2 -1
  68. package/dist/stores/authStore.js +3 -2
  69. package/package.json +1 -1
  70. package/scripts/verify-release-provenance.mjs +1 -0
  71. package/src/api-handlers/account/recovery/forward.ts +2 -1
  72. package/src/api-handlers/account/recovery/initiate.ts +2 -1
  73. package/src/api-handlers/account/reset-password.ts +2 -1
  74. package/src/api-handlers/account/send-code.ts +2 -1
  75. package/src/api-handlers/account/update-phone.ts +2 -1
  76. package/src/api-handlers/account/validate-password.ts +2 -1
  77. package/src/api-handlers/account/verify-email.ts +2 -1
  78. package/src/api-handlers/account/verify-sms.ts +2 -1
  79. package/src/api-handlers/anon/preferences.ts +3 -2
  80. package/src/api-handlers/auth/credential-login-email-code.ts +3 -2
  81. package/src/api-handlers/auth/credential-login.ts +6 -5
  82. package/src/api-handlers/auth/refresh.ts +74 -813
  83. package/src/api-handlers/auth/signout.ts +54 -7
  84. package/src/api-handlers/mfa/backup-code-verify.ts +2 -1
  85. package/src/api-handlers/mfa/totp-verify.ts +2 -1
  86. package/src/api-handlers/session/refresh-viability.ts +3 -2
  87. package/src/api-handlers/session/viability.ts +2 -1
  88. package/src/auth/better-auth.ts +7 -6
  89. package/src/auth/idp-oauth.ts +7 -2
  90. package/src/auth/utils/idp-client.ts +5 -4
  91. package/src/auth/utils/token-utils.ts +7 -5
  92. package/src/config/logger.ts +2 -1
  93. package/src/config/logging-config.ts +4 -3
  94. package/src/config/vibe-log-transport.ts +2 -1
  95. package/src/lib/api-handler.ts +4 -3
  96. package/src/lib/ensure-fresh-access-token.ts +214 -29
  97. package/src/lib/geolocation.ts +5 -4
  98. package/src/lib/idp-client-config.ts +4 -3
  99. package/src/lib/idp-fetch.ts +14 -3
  100. package/src/lib/idp-logout.ts +36 -1
  101. package/src/lib/jwt-decode-client.ts +3 -1
  102. package/src/lib/jwt-decode.ts +2 -1
  103. package/src/lib/log-safe.ts +30 -0
  104. package/src/lib/redis.ts +2 -1
  105. package/src/lib/refresh-in-doubt.ts +79 -0
  106. package/src/lib/refresh-token-validator.ts +2 -1
  107. package/src/lib/refresh-window.ts +16 -0
  108. package/src/lib/safe-error-response.ts +7 -2
  109. package/src/lib/safe-trace-id.ts +21 -0
  110. package/src/lib/session-store.ts +17 -4
  111. package/src/lib/site-logger.ts +13 -7
  112. package/src/lib/standardized-client-api.ts +3 -2
  113. package/src/lib/token-lifecycle.ts +6 -4
  114. package/src/middleware/create-middleware.ts +2 -1
  115. package/src/middleware/rbac-check.ts +2 -1
  116. package/src/models/SessionModel.ts +7 -0
  117. package/src/routes/auth/logout.ts +9 -2
  118. package/src/routes/auth/settings.ts +2 -1
  119. package/src/server/auth-guard.ts +4 -3
  120. package/src/server/auth.ts +6 -11
  121. package/src/server/decode-session.ts +3 -2
  122. package/src/server/with-auth.ts +2 -1
  123. package/src/stores/authStore.ts +3 -2
@@ -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,
@@ -0,0 +1,31 @@
1
+ /** How long a refresh fetch (connect, response and body) may take. 5 s lock wait + this + the persist stays well inside the 60 s lock TTL. */
2
+ export declare const REFRESH_FETCH_TIMEOUT_MS = 10000;
3
+ /** The typed terminal answer for an attempt that would re-send an in-doubt token. */
4
+ export declare const REFRESH_OUTCOME_UNKNOWN_CODE = "REFRESH_OUTCOME_UNKNOWN";
5
+ /** The typed answer for the timed-out call itself. */
6
+ export declare const UPSTREAM_TIMEOUT_CODE = "UPSTREAM_TIMEOUT";
7
+ /** A one-way identity for a refresh token. Not reversible and not a prefix. */
8
+ export declare function refreshTokenIdentity(token: string): string;
9
+ /** The row fields recorded at a timeout. */
10
+ export declare function inDoubtPatch(sentToken: string): {
11
+ refreshInDoubtTokenHash: string;
12
+ refreshInDoubtAt: number;
13
+ };
14
+ /** True when the token we are about to send is the one a previous attempt left in doubt. */
15
+ export declare function isRefreshInDoubt(session: {
16
+ refreshInDoubtTokenHash?: string | null;
17
+ } | null | undefined, tokenToSend: string | null | undefined): boolean;
18
+ /** True for the error `AbortSignal.timeout` produces (a DOMException named TimeoutError); AbortError is treated the same. */
19
+ export declare function isTimeoutError(err: unknown): boolean;
20
+ /**
21
+ * The ONLY transport error codes that PROVE the request never left this process (BAPert 76433 item 4): the connection was refused, or the
22
+ * name did not resolve. Anything else (ECONNRESET, UND_ERR_SOCKET, a timeout, an error with no code) may have happened AFTER the request was
23
+ * written, so the IdP may have consumed the token: outcome unknown. The cost is lopsided on purpose: a false in-doubt costs one re-login, a
24
+ * missed one costs a user-wide revocation. Do NOT add a code here without proof it can only occur before the request is sent.
25
+ */
26
+ export declare const REQUEST_NEVER_LEFT_CODES: ReadonlySet<string>;
27
+ /**
28
+ * True only when the error carries at least one cause code and EVERY code it carries is in the never-left allowlist. An error with no code
29
+ * is NOT proof, so it is in-doubt.
30
+ */
31
+ export declare function requestNeverLeft(err: unknown): boolean;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REQUEST_NEVER_LEFT_CODES = exports.UPSTREAM_TIMEOUT_CODE = exports.REFRESH_OUTCOME_UNKNOWN_CODE = exports.REFRESH_FETCH_TIMEOUT_MS = void 0;
4
+ exports.refreshTokenIdentity = refreshTokenIdentity;
5
+ exports.inDoubtPatch = inDoubtPatch;
6
+ exports.isRefreshInDoubt = isRefreshInDoubt;
7
+ exports.isTimeoutError = isTimeoutError;
8
+ exports.requestNeverLeft = requestNeverLeft;
9
+ /**
10
+ * The IdP fetch timeout and the "refresh in doubt" marker (BAPert 76397 decision 3, corrected by 76403; Core facts DotNetPert 76400).
11
+ *
12
+ * THE PROBLEM. Core consumes a refresh token with one atomic UPDATE and has NO reuse grace: re-presenting a used token revokes every newer
13
+ * refresh token the USER holds, on every device (RevokeDescendantTokens). So when OUR fetch to the IdP times out, we do not know whether Core
14
+ * consumed the token and minted a replacement whose answer we never saw. The store still holds the token we SENT (nothing was persisted).
15
+ * Holding the refresh lock only delays the next request by its 60 s TTL; after that the next request re-reads the same token and replays it.
16
+ *
17
+ * THE RULE. A timed-out refresh answers 504 UPSTREAM_TIMEOUT and records, on the session row, an IDENTITY (a SHA-256, never the token and
18
+ * never a prefix of it) of the token that was sent. Any later attempt whose token-to-send matches that identity refuses WITHOUT calling the
19
+ * IdP: terminal REFRESH_OUTCOME_UNKNOWN / REAUTHENTICATE. One login on one session is better than a user-wide revocation. The marker is
20
+ * dropped by the same write that stores a DIFFERENT refresh token (session-store `dropStaleDiscardMarker`); nothing tries to resolve the doubt
21
+ * without a login.
22
+ *
23
+ * Leaf module: no imports from the rest of the package, so session-store can use it without a cycle.
24
+ */
25
+ const crypto_1 = require("crypto");
26
+ /** How long a refresh fetch (connect, response and body) may take. 5 s lock wait + this + the persist stays well inside the 60 s lock TTL. */
27
+ exports.REFRESH_FETCH_TIMEOUT_MS = 10_000;
28
+ /** The typed terminal answer for an attempt that would re-send an in-doubt token. */
29
+ exports.REFRESH_OUTCOME_UNKNOWN_CODE = 'REFRESH_OUTCOME_UNKNOWN';
30
+ /** The typed answer for the timed-out call itself. */
31
+ exports.UPSTREAM_TIMEOUT_CODE = 'UPSTREAM_TIMEOUT';
32
+ /** A one-way identity for a refresh token. Not reversible and not a prefix. */
33
+ function refreshTokenIdentity(token) {
34
+ return (0, crypto_1.createHash)('sha256').update(token, 'utf8').digest('hex');
35
+ }
36
+ /** The row fields recorded at a timeout. */
37
+ function inDoubtPatch(sentToken) {
38
+ return { refreshInDoubtTokenHash: refreshTokenIdentity(sentToken), refreshInDoubtAt: Date.now() };
39
+ }
40
+ /** True when the token we are about to send is the one a previous attempt left in doubt. */
41
+ function isRefreshInDoubt(session, tokenToSend) {
42
+ if (!session || typeof session.refreshInDoubtTokenHash !== 'string' || !session.refreshInDoubtTokenHash)
43
+ return false;
44
+ if (typeof tokenToSend !== 'string' || tokenToSend.length === 0)
45
+ return false;
46
+ return refreshTokenIdentity(tokenToSend) === session.refreshInDoubtTokenHash;
47
+ }
48
+ /** True for the error `AbortSignal.timeout` produces (a DOMException named TimeoutError); AbortError is treated the same. */
49
+ function isTimeoutError(err) {
50
+ const name = err?.name;
51
+ return name === 'TimeoutError' || name === 'AbortError';
52
+ }
53
+ /**
54
+ * The ONLY transport error codes that PROVE the request never left this process (BAPert 76433 item 4): the connection was refused, or the
55
+ * name did not resolve. Anything else (ECONNRESET, UND_ERR_SOCKET, a timeout, an error with no code) may have happened AFTER the request was
56
+ * written, so the IdP may have consumed the token: outcome unknown. The cost is lopsided on purpose: a false in-doubt costs one re-login, a
57
+ * missed one costs a user-wide revocation. Do NOT add a code here without proof it can only occur before the request is sent.
58
+ */
59
+ exports.REQUEST_NEVER_LEFT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
60
+ /** Every `code` on the error, its `cause`, and the members of an AggregateError (undici wraps the socket error in `cause`). */
61
+ function transportCodes(err, depth = 0, out = []) {
62
+ if (!err || typeof err !== 'object' || depth > 4)
63
+ return out;
64
+ const e = err;
65
+ if (typeof e.code === 'string' && e.code)
66
+ out.push(e.code);
67
+ if (e.cause)
68
+ transportCodes(e.cause, depth + 1, out);
69
+ if (Array.isArray(e.errors))
70
+ for (const inner of e.errors)
71
+ transportCodes(inner, depth + 1, out);
72
+ return out;
73
+ }
74
+ /**
75
+ * True only when the error carries at least one cause code and EVERY code it carries is in the never-left allowlist. An error with no code
76
+ * is NOT proof, so it is in-doubt.
77
+ */
78
+ function requestNeverLeft(err) {
79
+ const codes = transportCodes(err);
80
+ return codes.length > 0 && codes.every((c) => exports.REQUEST_NEVER_LEFT_CODES.has(c));
81
+ }
@@ -7,6 +7,7 @@ exports.getRefreshTokenTimeRemaining = getRefreshTokenTimeRemaining;
7
7
  exports.checkRefreshViability = checkRefreshViability;
8
8
  const jwt_decode_1 = require("./jwt-decode");
9
9
  const logger_1 = require("../config/logger");
10
+ const refresh_window_1 = require("./refresh-window");
10
11
  function isRefreshTokenValid(token) {
11
12
  if (!token)
12
13
  return false;
@@ -81,7 +82,7 @@ function checkRefreshViability(sessionData) {
81
82
  if (expiresAtMs < 1000000000000)
82
83
  expiresAtMs = expiresAtMs * 1000;
83
84
  accessTokenTimeRemaining = Math.floor((expiresAtMs - now) / 1000);
84
- const bufferSec = 5 * 60; // 5 minutes pre-expiry buffer
85
+ const bufferSec = refresh_window_1.REFRESH_WINDOW_MS / 1000; // the one refresh window (lib/refresh-window), in seconds
85
86
  accessTokenExpired = accessTokenTimeRemaining <= bufferSec;
86
87
  logger_1.tokenRefreshLogger.debug('[REFRESH_VIABILITY] Access token expiration check', { now, expiresAtMs, accessTokenTimeRemaining, bufferSec, accessTokenExpired });
87
88
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * THE freshness window for an IdP access token (BAPert 76397 decision 1; Core facts DotNetPert 76400).
3
+ *
4
+ * An access token with this much life left or less is treated as stale and refreshed; with more, it is served as it is. One value for
5
+ * every path that decides: the mounted refresh route (`api-handlers/auth/refresh`), `ensureFreshAccessToken`, and `lib/idp-fetch`'s own
6
+ * pre-check. They used to disagree (5 min, 60 s, 5 min), so the same session was "stale" to one path and "fresh" to another, which is how
7
+ * two callers each rotated a token the other had just rotated.
8
+ *
9
+ * WHY 5 MINUTES: Core mints access tokens with a fixed 15-minute lifetime (a code constant, not configuration: Core
10
+ * `TokenConstants.ACCESS_TOKEN_LIFETIME_MINUTES = 15`, read-only in `TokenSettings`). The ruling is 5 minutes when the lifetime is at least 15
11
+ * minutes, and lifetime / 3 otherwise. If Core ever shortens the lifetime, this value MUST be revisited: a window at or above the lifetime
12
+ * makes every request refresh.
13
+ *
14
+ * A caller that needs another value passes it explicitly (`ensureFreshAccessToken`'s `safetyWindowMs` option still exists and still wins).
15
+ */
16
+ export declare const REFRESH_WINDOW_MS: number;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REFRESH_WINDOW_MS = void 0;
4
+ /**
5
+ * THE freshness window for an IdP access token (BAPert 76397 decision 1; Core facts DotNetPert 76400).
6
+ *
7
+ * An access token with this much life left or less is treated as stale and refreshed; with more, it is served as it is. One value for
8
+ * every path that decides: the mounted refresh route (`api-handlers/auth/refresh`), `ensureFreshAccessToken`, and `lib/idp-fetch`'s own
9
+ * pre-check. They used to disagree (5 min, 60 s, 5 min), so the same session was "stale" to one path and "fresh" to another, which is how
10
+ * two callers each rotated a token the other had just rotated.
11
+ *
12
+ * WHY 5 MINUTES: Core mints access tokens with a fixed 15-minute lifetime (a code constant, not configuration: Core
13
+ * `TokenConstants.ACCESS_TOKEN_LIFETIME_MINUTES = 15`, read-only in `TokenSettings`). The ruling is 5 minutes when the lifetime is at least 15
14
+ * minutes, and lifetime / 3 otherwise. If Core ever shortens the lifetime, this value MUST be revisited: a window at or above the lifetime
15
+ * makes every request refresh.
16
+ *
17
+ * A caller that needs another value passes it explicitly (`ensureFreshAccessToken`'s `safetyWindowMs` option still exists and still wins).
18
+ */
19
+ exports.REFRESH_WINDOW_MS = 5 * 60 * 1000;
@@ -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. */
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A caller-supplied trace id (the `x-request-id` header) is attacker-chosen text, and it goes to the log sink (PAY-2376; BAPert 76321 item 4,
3
+ * NightHawk 76316). Two problems if it is logged as it arrives: a value with a newline forges a log line, and an unbounded value is a log
4
+ * flood. This is the one place that makes it safe to log: control characters are removed, the length is capped, and anything that is not a
5
+ * non-empty string becomes `undefined` (so the log carries no trace id rather than a forged one).
6
+ *
7
+ * It is for LOG CORRELATION ONLY. It is never a lock identity and never a security decision (the lock id is minted server-side).
8
+ */
9
+ /** Longest trace id that is logged. 64 covers a UUID (36) and common trace formats; longer input is cut, not rejected. */
10
+ export declare const MAX_TRACE_ID_LENGTH = 64;
11
+ export declare function sanitizeTraceId(value: unknown): string | undefined;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ /**
3
+ * A caller-supplied trace id (the `x-request-id` header) is attacker-chosen text, and it goes to the log sink (PAY-2376; BAPert 76321 item 4,
4
+ * NightHawk 76316). Two problems if it is logged as it arrives: a value with a newline forges a log line, and an unbounded value is a log
5
+ * flood. This is the one place that makes it safe to log: control characters are removed, the length is capped, and anything that is not a
6
+ * non-empty string becomes `undefined` (so the log carries no trace id rather than a forged one).
7
+ *
8
+ * It is for LOG CORRELATION ONLY. It is never a lock identity and never a security decision (the lock id is minted server-side).
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.MAX_TRACE_ID_LENGTH = void 0;
12
+ exports.sanitizeTraceId = sanitizeTraceId;
13
+ /** Longest trace id that is logged. 64 covers a UUID (36) and common trace formats; longer input is cut, not rejected. */
14
+ exports.MAX_TRACE_ID_LENGTH = 64;
15
+ // C0 controls (incl. CR, LF, TAB, ESC), DEL, C1 controls (incl. NEL U+0085), and the Unicode line/paragraph separators U+2028 / U+2029.
16
+ // eslint-disable-next-line no-control-regex
17
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
18
+ function sanitizeTraceId(value) {
19
+ if (typeof value !== 'string')
20
+ return undefined;
21
+ const cleaned = value.replace(CONTROL_CHARS, '').slice(0, exports.MAX_TRACE_ID_LENGTH);
22
+ return cleaned.length > 0 ? cleaned : undefined;
23
+ }
@@ -43,6 +43,8 @@ const crypto_1 = require("crypto");
43
43
  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
+ const refresh_in_doubt_1 = require("./refresh-in-doubt");
47
+ const log_safe_1 = require("./log-safe");
46
48
  // Use app-slug prefixes for multi-app isolation
47
49
  const getSessionKey = (token) => `${(0, app_slug_1.getSessionPrefix)()}${token}`;
48
50
  const getRefreshLockKey = (token) => `${(0, app_slug_1.getRefreshLockPrefix)()}${token}`;
@@ -78,7 +80,7 @@ async function createSession(data) {
78
80
  .exec();
79
81
  }
80
82
  catch (error) {
81
- console.error('[SESSION-STORE] Failed to create session:', error);
83
+ console.error('[SESSION-STORE] Failed to create session:', (0, log_safe_1.errForLog)(error));
82
84
  throw error;
83
85
  }
84
86
  return sessionToken;
@@ -280,12 +282,21 @@ async function deleteSessionEverywhere(sessionToken, appSlug) {
280
282
  * today; this is the defence BAPert ruled to keep anyway.
281
283
  */
282
284
  function dropStaleDiscardMarker(session) {
283
- const s = session;
285
+ let s = session;
284
286
  if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
285
287
  const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
286
- return rest;
287
- }
288
- return session;
288
+ s = rest;
289
+ }
290
+ // BAPert 76403: the refresh-IN-DOUBT marker (lib/refresh-in-doubt) is cleared by the same write that stores a NEW refresh token, i.e. one
291
+ // whose identity differs from the one left in doubt. A write that stores the SAME token (any caller spreading the whole row back) does
292
+ // NOT clear it: that token is still the in-doubt one.
293
+ if (typeof s.refreshInDoubtTokenHash === 'string' &&
294
+ typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 &&
295
+ (0, refresh_in_doubt_1.refreshTokenIdentity)(s.idpRefreshToken) !== s.refreshInDoubtTokenHash) {
296
+ const { refreshInDoubtTokenHash: _hash, refreshInDoubtAt: _doubtAt, ...rest } = s;
297
+ s = rest;
298
+ }
299
+ return s;
289
300
  }
290
301
  async function setSession(sessionToken, data) {
291
302
  data = dropStaleDiscardMarker(data);
@@ -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,
@@ -48,6 +48,7 @@ exports.extractApiItems = extractApiItems;
48
48
  // NO MORE GUESSING data.data.data.data - EVER AGAIN!
49
49
  // ========================================================================================
50
50
  const better_auth_client_1 = require("../client/better-auth-client");
51
+ const log_safe_1 = require("./log-safe");
51
52
  /** Unwrap Better Auth getSession() to a flat session-like object for backward compat */
52
53
  async function getSession() {
53
54
  const { data } = await better_auth_client_1.authClient.getSession();
@@ -172,7 +173,7 @@ function scheduleLoginRedirect(isImmediate = false) {
172
173
  window.location.href = `/account-auth/login?returnUrl=${returnUrl}`;
173
174
  }
174
175
  catch (error) {
175
- console.error('❌ Error during login redirect:', error);
176
+ console.error('❌ Error during login redirect:', (0, log_safe_1.errForLog)(error));
176
177
  // Final fallback
177
178
  try {
178
179
  window.location.href = '/account-auth/login';
@@ -640,7 +641,7 @@ class StandardizedClientApiService {
640
641
  throw error;
641
642
  }
642
643
  // Wrap unknown errors as network errors
643
- console.error('❌ API request failed:', error);
644
+ console.error('❌ API request failed:', (0, log_safe_1.errForLog)(error));
644
645
  throw new ApiNetworkError(error instanceof Error ? error.message : 'Network error', 0, endpoint);
645
646
  }
646
647
  }
@@ -28,8 +28,10 @@ const auth_1 = require("../server/auth");
28
28
  const redis_1 = require("./redis");
29
29
  const app_slug_1 = require("./app-slug");
30
30
  const token_log_id_1 = require("./token-log-id");
31
- // 5 minute threshold for "needs refresh" - matches refresh handler pattern
32
- const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
31
+ const refresh_window_1 = require("./refresh-window");
32
+ const log_safe_1 = require("./log-safe");
33
+ // "needs refresh" threshold: THE one freshness window (lib/refresh-window), the same value the refresh route, ensureFreshAccessToken and idp-fetch use.
34
+ const REFRESH_THRESHOLD_MS = refresh_window_1.REFRESH_WINDOW_MS;
33
35
  // Concurrent refresh handling configuration
34
36
  const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
35
37
  const CONCURRENT_REFRESH_MAX_WAIT_MS = 8000; // Max time to wait for concurrent refresh to complete
@@ -357,7 +359,7 @@ async function ensureFreshToken(request) {
357
359
  }
358
360
  catch (e) {
359
361
  // If we can't decode, proceed with normal logic
360
- console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
362
+ console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', (0, log_safe_1.errForLog)(e));
361
363
  }
362
364
  }
363
365
  console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
@@ -428,7 +430,7 @@ async function ensureFreshToken(request) {
428
430
  };
429
431
  }
430
432
  catch (error) {
431
- console.error('[TOKEN_LIFECYCLE] Error:', error);
433
+ console.error('[TOKEN_LIFECYCLE] Error:', (0, log_safe_1.errForLog)(error));
432
434
  return {
433
435
  success: false,
434
436
  error: 'NO_SESSION',
@@ -31,6 +31,7 @@ const app_slug_1 = require("../lib/app-slug");
31
31
  const rbac_check_1 = require("./rbac-check");
32
32
  const session_cookie_clear_1 = require("../auth/session-cookie-clear");
33
33
  const session_reset_link_1 = require("../auth/session-reset-link");
34
+ const log_safe_1 = require("../lib/log-safe");
34
35
  // =============================================================================
35
36
  // DEFAULT IMPLEMENTATIONS
36
37
  // =============================================================================
@@ -748,7 +749,7 @@ async function handleAllow(request, pathname, sessionPointer, sessionStatus, rba
748
749
  }
749
750
  }
750
751
  catch (error) {
751
- console.error('[MIDDLEWARE] RBAC error:', error);
752
+ console.error('[MIDDLEWARE] RBAC error:', (0, log_safe_1.errForLog)(error));
752
753
  // In development, fail open
753
754
  if (process.env.NODE_ENV !== 'production') {
754
755
  console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
@@ -1,16 +1,3 @@
1
- /**
2
- * Page RBAC Check Module
3
- *
4
- * Checks page-level permissions via Vibe API through the IDP Proxy.
5
- * Uses in-memory cache to reduce API calls.
6
- * Fails closed (DENY) on errors or timeout.
7
- *
8
- * All requests route through the IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy)
9
- * which injects proper HMAC credentials for the Vibe API.
10
- *
11
- * @version 2.0.0
12
- * @since page-rbac-2026-01
13
- */
14
1
  export interface RBACResult {
15
2
  allowed: boolean;
16
3
  requires_2fa?: boolean;
@@ -1,4 +1,9 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clearRBACCache = clearRBACCache;
4
+ exports.checkPagePermission = checkPagePermission;
5
+ exports.isRBACEnabled = isRBACEnabled;
6
+ const log_safe_1 = require("../lib/log-safe");
2
7
  /**
3
8
  * Page RBAC Check Module
4
9
  *
@@ -12,10 +17,6 @@
12
17
  * @version 2.0.0
13
18
  * @since page-rbac-2026-01
14
19
  */
15
- Object.defineProperty(exports, "__esModule", { value: true });
16
- exports.clearRBACCache = clearRBACCache;
17
- exports.checkPagePermission = checkPagePermission;
18
- exports.isRBACEnabled = isRBACEnabled;
19
20
  // ============================================================================
20
21
  // WEB CRYPTO HELPERS (Edge Runtime compatible)
21
22
  // ============================================================================
@@ -203,7 +204,7 @@ async function checkPagePermission(path, userRoles, clientId, userClaims) {
203
204
  redirect: '/error?code=rbac_timeout',
204
205
  };
205
206
  }
206
- console.error('[RBAC] Proxy error:', error);
207
+ console.error('[RBAC] Proxy error:', (0, log_safe_1.errForLog)(error));
207
208
  return {
208
209
  allowed: false,
209
210
  reason: 'rbac_service_unavailable',
@@ -38,6 +38,13 @@ export interface SessionData {
38
38
  idpAccessTokenExpires: number;
39
39
  /** When the IDP refresh token expires (Unix timestamp ms) */
40
40
  idpRefreshTokenExpires?: number;
41
+ /**
42
+ * BAPert 76403 (lib/refresh-in-doubt): set when a refresh fetch TIMED OUT. A SHA-256 identity of the refresh token that was sent (never the
43
+ * token), and when. A later attempt whose token-to-send matches refuses without calling the IdP. Cleared by the write that stores a different
44
+ * refresh token.
45
+ */
46
+ refreshInDoubtTokenHash?: string;
47
+ refreshInDoubtAt?: number;
41
48
  /** Decoded IDP access token claims (for quick access without re-decoding) */
42
49
  decodedAccessToken?: any;
43
50
  /**