@payez/next-mvp 4.4.5 → 4.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +10 -3
  2. package/dist/api/auth-handler.js +5 -3
  3. package/dist/api-handlers/account/change-password.js +33 -8
  4. package/dist/api-handlers/account/masked-info.js +2 -1
  5. package/dist/api-handlers/account/recovery/forward.d.ts +14 -0
  6. package/dist/api-handlers/account/recovery/forward.js +105 -0
  7. package/dist/api-handlers/account/recovery/initiate.js +4 -0
  8. package/dist/api-handlers/account/recovery/send-code.d.ts +12 -2
  9. package/dist/api-handlers/account/recovery/send-code.js +11 -84
  10. package/dist/api-handlers/account/recovery/verify-code.d.ts +11 -2
  11. package/dist/api-handlers/account/recovery/verify-code.js +10 -84
  12. package/dist/api-handlers/account/reset-password.js +24 -1
  13. package/dist/api-handlers/account/send-code.d.ts +1 -15
  14. package/dist/api-handlers/account/send-code.js +3 -6
  15. package/dist/api-handlers/account/update-phone.js +6 -1
  16. package/dist/api-handlers/account/validate-password.js +3 -1
  17. package/dist/api-handlers/account/verify-email.d.ts +1 -17
  18. package/dist/api-handlers/account/verify-email.js +4 -6
  19. package/dist/api-handlers/account/verify-sms.d.ts +1 -17
  20. package/dist/api-handlers/account/verify-sms.js +4 -6
  21. package/dist/api-handlers/admin/analytics.js +12 -4
  22. package/dist/api-handlers/admin/audit.js +12 -4
  23. package/dist/api-handlers/admin/redis-sessions.js +16 -8
  24. package/dist/api-handlers/admin/sessions.js +14 -6
  25. package/dist/api-handlers/admin/site-logs.js +39 -16
  26. package/dist/api-handlers/admin/stats.js +14 -4
  27. package/dist/api-handlers/admin/users.js +14 -6
  28. package/dist/api-handlers/admin/vibe-data.js +19 -11
  29. package/dist/api-handlers/auth/jwks.js +2 -1
  30. package/dist/api-handlers/auth/login.js +13 -4
  31. package/dist/api-handlers/auth/passkey-signin.js +6 -0
  32. package/dist/api-handlers/auth/refresh.js +38 -11
  33. package/dist/api-handlers/auth/signout.js +2 -1
  34. package/dist/api-handlers/auth/status.js +4 -1
  35. package/dist/api-handlers/auth/update-session.d.ts +25 -4
  36. package/dist/api-handlers/auth/update-session.js +38 -11
  37. package/dist/api-handlers/auth/validate.js +4 -2
  38. package/dist/api-handlers/auth/verify-code.js +3 -2
  39. package/dist/api-handlers/mfa/backup-code-verify.d.ts +1 -18
  40. package/dist/api-handlers/mfa/backup-code-verify.js +4 -6
  41. package/dist/api-handlers/mfa/backup-codes.js +2 -1
  42. package/dist/api-handlers/mfa/methods.js +2 -1
  43. package/dist/api-handlers/mfa/totp-confirm.js +2 -1
  44. package/dist/api-handlers/mfa/totp-enroll.js +2 -1
  45. package/dist/api-handlers/mfa/totp-verify.d.ts +1 -19
  46. package/dist/api-handlers/mfa/totp-verify.js +4 -6
  47. package/dist/api-handlers/passkeys/list.js +2 -1
  48. package/dist/api-handlers/passkeys/manage.js +2 -1
  49. package/dist/api-handlers/passkeys/register-options.js +2 -1
  50. package/dist/api-handlers/passkeys/register-verify.js +2 -1
  51. package/dist/api-handlers/session/refresh-viability.d.ts +4 -2
  52. package/dist/api-handlers/session/refresh-viability.js +10 -7
  53. package/dist/api-handlers/session/viability.js +4 -3
  54. package/dist/api-handlers/test/force-expire.js +5 -3
  55. package/dist/auth/auth-decision.js +2 -1
  56. package/dist/auth/better-auth.d.ts +58 -14
  57. package/dist/auth/better-auth.js +125 -54
  58. package/dist/auth/utils/idp-client.js +2 -1
  59. package/dist/client/better-auth-client.d.ts +101 -956
  60. package/dist/components/admin/DataBrowserTab.js +4 -1
  61. package/dist/components/admin/SessionsTab.js +9 -3
  62. package/dist/components/recovery/RequestedStep.d.ts +11 -0
  63. package/dist/components/recovery/RequestedStep.js +14 -0
  64. package/dist/components/recovery/SetPasswordStep.js +10 -1
  65. package/dist/config/env.d.ts +0 -2
  66. package/dist/config/env.js +0 -2
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +8 -1
  69. package/dist/lib/app-slug.d.ts +21 -0
  70. package/dist/lib/app-slug.js +51 -0
  71. package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
  72. package/dist/lib/ensure-fresh-access-token.js +105 -4
  73. package/dist/lib/idp-client-config.d.ts +20 -0
  74. package/dist/lib/idp-client-config.js +73 -45
  75. package/dist/lib/idp-fetch.js +1 -1
  76. package/dist/lib/never-in-response.d.ts +30 -0
  77. package/dist/lib/never-in-response.js +71 -0
  78. package/dist/lib/password-rules.d.ts +22 -0
  79. package/dist/lib/password-rules.js +43 -0
  80. package/dist/lib/safe-error-response.d.ts +67 -0
  81. package/dist/lib/safe-error-response.js +130 -0
  82. package/dist/lib/session-cookie-shape.d.ts +22 -19
  83. package/dist/lib/session-cookie-shape.js +14 -18
  84. package/dist/lib/session-store.d.ts +8 -1
  85. package/dist/lib/session-store.js +170 -24
  86. package/dist/lib/startup-init.d.ts +6 -0
  87. package/dist/lib/startup-init.js +30 -4
  88. package/dist/lib/token-lifecycle.js +2 -1
  89. package/dist/lib/token-log-id.d.ts +18 -0
  90. package/dist/lib/token-log-id.js +84 -0
  91. package/dist/lib/two-factor-verify-failure.d.ts +78 -0
  92. package/dist/lib/two-factor-verify-failure.js +220 -0
  93. package/dist/middleware/create-middleware.d.ts +4 -1
  94. package/dist/middleware/create-middleware.js +79 -8
  95. package/dist/pages/login/page.js +5 -0
  96. package/dist/pages/recovery/page.js +14 -4
  97. package/dist/pages/test-env/JwtInspectPage.js +7 -149
  98. package/dist/pages/test-env/RefreshTokenPage.js +4 -1
  99. package/dist/pages/test-env/redact-credentials.d.ts +6 -0
  100. package/dist/pages/test-env/redact-credentials.js +101 -0
  101. package/dist/pages/verify-code/page.js +6 -3
  102. package/dist/routes/auth/logout.d.ts +2 -1
  103. package/dist/routes/auth/logout.js +4 -2
  104. package/dist/routes/auth/session.d.ts +0 -3
  105. package/dist/routes/auth/session.js +16 -10
  106. package/dist/routes/auth/settings.js +4 -1
  107. package/dist/routes/auth/viability.d.ts +3 -2
  108. package/dist/routes/auth/viability.js +11 -7
  109. package/dist/server/auth.d.ts +36 -294
  110. package/dist/server/auth.js +32 -31
  111. package/dist/server/decode-session.js +15 -26
  112. package/dist/server/verified-session-token.d.ts +13 -0
  113. package/dist/server/verified-session-token.js +103 -0
  114. package/dist/stores/authStore.js +14 -3
  115. package/dist/types/recovery.d.ts +1 -1
  116. package/dist/utils/api.d.ts +18 -0
  117. package/dist/utils/api.js +51 -9
  118. package/dist/vibe/client.d.ts +132 -16
  119. package/dist/vibe/client.js +288 -82
  120. package/dist/vibe/document-unwrap.d.ts +50 -0
  121. package/dist/vibe/document-unwrap.js +78 -0
  122. package/dist/vibe/errors.d.ts +17 -0
  123. package/dist/vibe/errors.js +21 -1
  124. package/dist/vibe/generic.d.ts +2 -49
  125. package/dist/vibe/generic.js +17 -78
  126. package/dist/vibe/index.d.ts +1 -1
  127. package/dist/vibe/index.js +2 -1
  128. package/dist/vibe/sessions.js +2 -1
  129. package/package.json +13 -2
  130. package/scripts/verify-release-provenance.mjs +410 -0
  131. package/src/api/auth-handler.ts +5 -3
  132. package/src/api-handlers/account/change-password.ts +36 -8
  133. package/src/api-handlers/account/masked-info.ts +2 -1
  134. package/src/api-handlers/account/recovery/forward.ts +117 -0
  135. package/src/api-handlers/account/recovery/initiate.ts +3 -0
  136. package/src/api-handlers/account/recovery/send-code.ts +13 -99
  137. package/src/api-handlers/account/recovery/verify-code.ts +12 -99
  138. package/src/api-handlers/account/reset-password.ts +30 -1
  139. package/src/api-handlers/account/send-code.ts +3 -9
  140. package/src/api-handlers/account/update-phone.ts +6 -1
  141. package/src/api-handlers/account/validate-password.ts +3 -1
  142. package/src/api-handlers/account/verify-email.ts +4 -9
  143. package/src/api-handlers/account/verify-sms.ts +4 -9
  144. package/src/api-handlers/admin/analytics.ts +13 -5
  145. package/src/api-handlers/admin/audit.ts +13 -5
  146. package/src/api-handlers/admin/redis-sessions.ts +16 -8
  147. package/src/api-handlers/admin/sessions.ts +15 -7
  148. package/src/api-handlers/admin/site-logs.ts +39 -16
  149. package/src/api-handlers/admin/stats.ts +15 -5
  150. package/src/api-handlers/admin/users.ts +15 -7
  151. package/src/api-handlers/admin/vibe-data.ts +20 -12
  152. package/src/api-handlers/auth/jwks.ts +2 -1
  153. package/src/api-handlers/auth/login.ts +13 -4
  154. package/src/api-handlers/auth/passkey-signin.ts +4 -0
  155. package/src/api-handlers/auth/refresh.ts +38 -11
  156. package/src/api-handlers/auth/signout.ts +2 -1
  157. package/src/api-handlers/auth/status.ts +4 -1
  158. package/src/api-handlers/auth/update-session.ts +45 -11
  159. package/src/api-handlers/auth/validate.ts +4 -2
  160. package/src/api-handlers/auth/verify-code.ts +3 -2
  161. package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
  162. package/src/api-handlers/mfa/backup-codes.ts +2 -1
  163. package/src/api-handlers/mfa/methods.ts +2 -1
  164. package/src/api-handlers/mfa/totp-confirm.ts +2 -1
  165. package/src/api-handlers/mfa/totp-enroll.ts +2 -1
  166. package/src/api-handlers/mfa/totp-verify.ts +4 -9
  167. package/src/api-handlers/passkeys/list.ts +2 -1
  168. package/src/api-handlers/passkeys/manage.ts +2 -1
  169. package/src/api-handlers/passkeys/register-options.ts +2 -1
  170. package/src/api-handlers/passkeys/register-verify.ts +2 -1
  171. package/src/api-handlers/session/refresh-viability.ts +10 -7
  172. package/src/api-handlers/session/viability.ts +4 -3
  173. package/src/api-handlers/test/force-expire.ts +5 -3
  174. package/src/auth/auth-decision.ts +2 -1
  175. package/src/auth/better-auth.ts +128 -61
  176. package/src/auth/utils/idp-client.ts +2 -1
  177. package/src/components/admin/DataBrowserTab.tsx +4 -1
  178. package/src/components/admin/SessionsTab.tsx +10 -3
  179. package/src/components/recovery/RequestedStep.tsx +30 -0
  180. package/src/components/recovery/SetPasswordStep.tsx +24 -3
  181. package/src/config/env.ts +0 -2
  182. package/src/hooks/useViabilitySession.ts +2 -1
  183. package/src/index.ts +4 -0
  184. package/src/lib/app-slug.ts +50 -0
  185. package/src/lib/ensure-fresh-access-token.ts +133 -4
  186. package/src/lib/idp-client-config.ts +98 -45
  187. package/src/lib/idp-fetch.ts +1 -1
  188. package/src/lib/never-in-response.ts +64 -0
  189. package/src/lib/password-rules.ts +50 -0
  190. package/src/lib/safe-error-response.ts +126 -0
  191. package/src/lib/session-cookie-shape.ts +23 -22
  192. package/src/lib/session-store.ts +167 -24
  193. package/src/lib/startup-init.ts +29 -4
  194. package/src/lib/token-lifecycle.ts +2 -1
  195. package/src/lib/token-log-id.ts +69 -0
  196. package/src/lib/two-factor-verify-failure.ts +248 -0
  197. package/src/middleware/create-middleware.ts +97 -9
  198. package/src/pages/login/page.tsx +4 -0
  199. package/src/pages/recovery/page.tsx +21 -4
  200. package/src/pages/test-env/JwtInspectPage.tsx +11 -233
  201. package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
  202. package/src/pages/test-env/redact-credentials.ts +96 -0
  203. package/src/pages/verify-code/page.tsx +6 -3
  204. package/src/routes/auth/logout.ts +4 -2
  205. package/src/routes/auth/session.ts +16 -10
  206. package/src/routes/auth/settings.ts +5 -1
  207. package/src/routes/auth/viability.ts +11 -7
  208. package/src/server/auth.ts +33 -35
  209. package/src/server/decode-session.ts +16 -26
  210. package/src/server/verified-session-token.ts +103 -0
  211. package/src/stores/authStore.ts +16 -5
  212. package/src/types/recovery.ts +1 -1
  213. package/src/utils/api.ts +47 -8
  214. package/src/vibe/client.ts +378 -91
  215. package/src/vibe/document-unwrap.ts +103 -0
  216. package/src/vibe/errors.ts +24 -0
  217. package/src/vibe/generic.ts +8 -100
  218. package/src/vibe/index.ts +1 -0
  219. package/src/vibe/sessions.ts +2 -1
@@ -174,7 +174,10 @@ function DataBrowserTab({ isDark = true, apiBasePath = '/api/admin/vibe', focusC
174
174
  });
175
175
  if (!response.ok) {
176
176
  const errData = await response.json().catch(() => ({}));
177
- throw new Error(errData.error?.message || 'Delete failed');
177
+ // PAY-1425: the handler returns a fixed message plus a correlationId (never upstream
178
+ // text). Surface the id so an admin can quote it to an operator.
179
+ const ref = errData.error?.correlationId ? ` (ref ${errData.error.correlationId})` : '';
180
+ throw new Error(`${errData.error?.message || 'Delete failed'}${ref}`);
178
181
  }
179
182
  setSuccess(`Record ${id} deleted`);
180
183
  setTimeout(() => setSuccess(null), 3000);
@@ -7,6 +7,12 @@ const react_1 = require("react");
7
7
  // -----------------------------------------------------------------------------
8
8
  // COMPONENT
9
9
  // -----------------------------------------------------------------------------
10
+ // PAY-1429: admin handlers return a fixed message plus a correlationId (never upstream or
11
+ // exception text). Surface the id so an admin can quote it to an operator.
12
+ function errorWithRef(data, fallback) {
13
+ const msg = typeof data?.error === 'string' ? data.error : fallback;
14
+ return data?.correlationId ? `${msg} (ref ${data.correlationId})` : msg;
15
+ }
10
16
  function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions' }) {
11
17
  const [sessions, setSessions] = (0, react_1.useState)([]);
12
18
  const [isLoading, setIsLoading] = (0, react_1.useState)(false);
@@ -41,7 +47,7 @@ function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions' }) {
41
47
  setLastRefresh(new Date());
42
48
  }
43
49
  else {
44
- setError(data.error || 'Failed to fetch sessions');
50
+ setError(errorWithRef(data, 'Failed to fetch sessions'));
45
51
  }
46
52
  }
47
53
  catch (err) {
@@ -91,7 +97,7 @@ function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions' }) {
91
97
  await fetchStats();
92
98
  }
93
99
  else {
94
- alert(data.error || 'Failed to revoke session');
100
+ alert(errorWithRef(data, 'Failed to revoke session'));
95
101
  }
96
102
  }
97
103
  catch (err) {
@@ -120,7 +126,7 @@ function SessionsTab({ isDark = true, apiBasePath = '/api/admin/sessions' }) {
120
126
  await fetchStats();
121
127
  }
122
128
  else {
123
- alert(data.error || 'Failed to revoke sessions');
129
+ alert(errorWithRef(data, 'Failed to revoke sessions'));
124
130
  }
125
131
  }
126
132
  catch (err) {
@@ -0,0 +1,11 @@
1
+ interface Props {
2
+ onGoToLogin: () => void;
3
+ }
4
+ /**
5
+ * PAY-1588 (rigpert 60711): what a caller sees when the recovery request produced NO recovery session - an unknown
6
+ * address, a locked account, a throttled caller. ONE sentence, identical for all of them, so the screen cannot be used
7
+ * to tell which. It must not claim a reset happened (that is CompleteStep, reached only after a successful reset) and it
8
+ * must not name a cause.
9
+ */
10
+ export declare function RequestedStep({ onGoToLogin }: Props): import("react/jsx-runtime").JSX.Element;
11
+ export {};
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.RequestedStep = RequestedStep;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ /**
7
+ * PAY-1588 (rigpert 60711): what a caller sees when the recovery request produced NO recovery session - an unknown
8
+ * address, a locked account, a throttled caller. ONE sentence, identical for all of them, so the screen cannot be used
9
+ * to tell which. It must not claim a reset happened (that is CompleteStep, reached only after a successful reset) and it
10
+ * must not name a cause.
11
+ */
12
+ function RequestedStep({ onGoToLogin }) {
13
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "bg-white border border-gray-300 rounded p-8 text-center", children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-2xl font-semibold text-gray-900 mb-2", children: "Check your email" }), (0, jsx_runtime_1.jsx)("p", { className: "text-gray-600 mb-6", children: "If an account exists for that email, we've sent instructions to reset your password." }), (0, jsx_runtime_1.jsx)("button", { onClick: onGoToLogin, className: "w-full py-2 px-4 border border-gray-300 rounded text-gray-900 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400", children: "Back to sign in" })] }));
14
+ }
@@ -4,17 +4,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.SetPasswordStep = SetPasswordStep;
5
5
  const jsx_runtime_1 = require("react/jsx-runtime");
6
6
  const react_1 = require("react");
7
+ const usePasswordValidation_1 = require("../../hooks/usePasswordValidation");
8
+ const password_rules_1 = require("../../lib/password-rules");
7
9
  function SetPasswordStep({ onSubmit, loading }) {
8
10
  const [password, setPassword] = (0, react_1.useState)('');
9
11
  const [confirmPassword, setConfirmPassword] = (0, react_1.useState)('');
10
12
  const [showPassword, setShowPassword] = (0, react_1.useState)(false);
11
13
  const [showConfirmPassword, setShowConfirmPassword] = (0, react_1.useState)(false);
12
14
  const passwordsMatch = password === confirmPassword && password !== '';
15
+ // PAY-1591: ADVISORY only. It shows the rules and what is missing; it never disables submit, because the validator
16
+ // may be down and the IdP is the authority (a dead validator must not trap a user who already spent their code).
17
+ const { setPassword: validateAsYouType, policy, failedRequirements } = (0, usePasswordValidation_1.usePasswordValidation)();
18
+ // The policy only comes back with a validation, so ask once on mount with a one-character probe (it never leaves the
19
+ // policy question; the answer is what the rules line is built from). Until it lands the line says the rules are coming.
20
+ (0, react_1.useEffect)(() => { validateAsYouType('a'); }, [validateAsYouType]);
21
+ const missing = failedRequirements.filter((r) => !/^(Unable to validate|Password validation)/i.test(r));
13
22
  const handleSubmit = (e) => {
14
23
  e.preventDefault();
15
24
  if (passwordsMatch) {
16
25
  onSubmit(password, confirmPassword);
17
26
  }
18
27
  };
19
- return ((0, jsx_runtime_1.jsxs)("div", { className: "bg-white border border-gray-300 rounded p-8", children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-xl font-semibold text-gray-900 mb-2", children: "Set New Password" }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-gray-600 mb-6", children: "Choose a strong password for your account." }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "password", className: "block text-sm font-medium text-gray-900 mb-1", children: "New Password" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsx)("input", { type: showPassword ? 'text' : 'password', id: "password", value: password, onChange: (e) => setPassword(e.target.value), required: true, disabled: loading, className: "w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-gray-900 focus:outline-none disabled:opacity-50 disabled:bg-gray-50 pr-10" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setShowPassword(!showPassword), className: "absolute right-2 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-900", children: showPassword ? '👁️' : '👁️‍🗨️' })] })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "confirmPassword", className: "block text-sm font-medium text-gray-900 mb-1", children: "Confirm New Password" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsx)("input", { type: showConfirmPassword ? 'text' : 'password', id: "confirmPassword", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true, disabled: loading, className: "w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-gray-900 focus:outline-none disabled:opacity-50 disabled:bg-gray-50 pr-10" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setShowConfirmPassword(!showConfirmPassword), className: "absolute right-2 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-900", children: showConfirmPassword ? '👁️' : '👁️‍🗨️' })] })] }), !passwordsMatch && confirmPassword && ((0, jsx_runtime_1.jsx)("div", { className: "bg-red-50 border border-red-200 rounded p-3", children: (0, jsx_runtime_1.jsx)("p", { className: "text-red-700 text-sm", children: "Passwords do not match" }) })), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: !passwordsMatch || loading, className: "w-full py-2 px-4 border border-gray-300 rounded text-gray-900 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed", children: loading ? 'Resetting Password...' : 'Reset Password' })] })] }));
28
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "bg-white border border-gray-300 rounded p-8", children: [(0, jsx_runtime_1.jsx)("h2", { className: "text-xl font-semibold text-gray-900 mb-2", children: "Set New Password" }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-gray-600 mb-2", children: "Choose a strong password for your account." }), (0, jsx_runtime_1.jsx)("p", { "data-testid": "password-rules", className: "text-sm text-gray-700 mb-6", children: (0, password_rules_1.passwordRulesText)(policy) }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "space-y-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "password", className: "block text-sm font-medium text-gray-900 mb-1", children: "New Password" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsx)("input", { type: showPassword ? 'text' : 'password', id: "password", value: password, onChange: (e) => { setPassword(e.target.value); validateAsYouType(e.target.value); }, required: true, disabled: loading, className: "w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-gray-900 focus:outline-none disabled:opacity-50 disabled:bg-gray-50 pr-10" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setShowPassword(!showPassword), className: "absolute right-2 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-900", children: showPassword ? '👁️' : '👁️‍🗨️' })] })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "confirmPassword", className: "block text-sm font-medium text-gray-900 mb-1", children: "Confirm New Password" }), (0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [(0, jsx_runtime_1.jsx)("input", { type: showConfirmPassword ? 'text' : 'password', id: "confirmPassword", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true, disabled: loading, className: "w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-gray-900 focus:outline-none disabled:opacity-50 disabled:bg-gray-50 pr-10" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setShowConfirmPassword(!showConfirmPassword), className: "absolute right-2 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-900", children: showConfirmPassword ? '👁️' : '👁️‍🗨️' })] })] }), password && missing.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { "data-testid": "password-missing", className: "text-sm text-gray-700 list-disc pl-5", children: missing.map((r) => ((0, jsx_runtime_1.jsx)("li", { children: r }, r))) })), !passwordsMatch && confirmPassword && ((0, jsx_runtime_1.jsx)("div", { className: "bg-red-50 border border-red-200 rounded p-3", children: (0, jsx_runtime_1.jsx)("p", { className: "text-red-700 text-sm", children: "Passwords do not match" }) })), (0, jsx_runtime_1.jsx)("button", { type: "submit", disabled: !passwordsMatch || loading, className: "w-full py-2 px-4 border border-gray-300 rounded text-gray-900 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed", children: loading ? 'Resetting Password...' : 'Reset Password' })] })] }));
20
29
  }
@@ -19,8 +19,6 @@ export declare const API_ENDPOINTS: {
19
19
  readonly profile: "/api/account/profile";
20
20
  readonly updateProfile: "/api/account/profile";
21
21
  readonly maskedInfo: "/api/account/masked-info";
22
- readonly sendResetCode: "/api/account/send-reset-code";
23
- readonly verifyResetCode: "/api/account/verify-reset-code";
24
22
  readonly resetPassword: "/api/account/reset-password";
25
23
  readonly changePassword: "/api/account/change-password";
26
24
  readonly validatePassword: "/api/account/validate-password";
@@ -29,8 +29,6 @@ exports.API_ENDPOINTS = {
29
29
  profile: '/api/account/profile',
30
30
  updateProfile: '/api/account/profile',
31
31
  maskedInfo: '/api/account/masked-info',
32
- sendResetCode: '/api/account/send-reset-code',
33
- verifyResetCode: '/api/account/verify-reset-code',
34
32
  resetPassword: '/api/account/reset-password',
35
33
  changePassword: '/api/account/change-password',
36
34
  validatePassword: '/api/account/validate-password'
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { AuthProvider, useAuthConfig, useAuthMode, useFederatedProviders, useFed
5
5
  export type { AuthConfig } from './types/auth';
6
6
  export { makeAuthDecision } from './auth/auth-decision';
7
7
  export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from './auth/route-config';
8
+ export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
8
9
  export { createMvpMiddleware } from './middleware/create-middleware';
9
10
  export { UserAvatarMenu, MobileNavDrawer } from './components/account';
10
11
  export type { UserAvatarMenuProps, MobileNavDrawerProps, NavItem, NavSection } from './components/account';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // - Better Auth: import { createBetterAuthInstance } from '@payez/next-mvp/auth/better-auth'
9
9
  // - Server auth: import { getSession } from '@payez/next-mvp/server/auth'
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.createMvpMiddleware = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
11
+ exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
12
12
  // Client-safe exports only
13
13
  // Client-side utilities
14
14
  var fetch_with_auth_1 = require("./client/fetch-with-auth");
@@ -31,6 +31,13 @@ var route_config_1 = require("./auth/route-config");
31
31
  Object.defineProperty(exports, "isUnauthenticatedRoute", { enumerable: true, get: function () { return route_config_1.isUnauthenticatedRoute; } });
32
32
  Object.defineProperty(exports, "configurePublicRoutes", { enumerable: true, get: function () { return route_config_1.configurePublicRoutes; } });
33
33
  Object.defineProperty(exports, "getRouteConfig", { enumerable: true, get: function () { return route_config_1.getRouteConfig; } });
34
+ // The ONE callback-target guard (N7 / PAY-1989): apps import this instead of
35
+ // copying it. Exposed from the main index AND as the subpath ./auth/safe-callback.
36
+ var safe_callback_1 = require("./auth/safe-callback");
37
+ Object.defineProperty(exports, "safeCallbackPath", { enumerable: true, get: function () { return safe_callback_1.safeCallbackPath; } });
38
+ Object.defineProperty(exports, "isSafeCallbackPath", { enumerable: true, get: function () { return safe_callback_1.isSafeCallbackPath; } });
39
+ Object.defineProperty(exports, "resolveCallbackDestination", { enumerable: true, get: function () { return safe_callback_1.resolveCallbackDestination; } });
40
+ Object.defineProperty(exports, "DEFAULT_CALLBACK", { enumerable: true, get: function () { return safe_callback_1.DEFAULT_CALLBACK; } });
34
41
  var create_middleware_1 = require("./middleware/create-middleware");
35
42
  Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
36
43
  // Account Components
@@ -14,6 +14,27 @@
14
14
  * Caches the result for performance.
15
15
  */
16
16
  export declare function getAppSlug(): string;
17
+ /**
18
+ * The session cookie name has TWO producers: better-auth mints `${idpConfig.clientSlug || getAppSlug()}.session-token`, while
19
+ * the gate and every reader use getSessionCookieName() = getAppSlug() = APP_SLUG || CLIENT_ID. When the IdP's slug is known and
20
+ * differs, the gate looks for a cookie that was never set and every better-auth (Google) session bounces to
21
+ * /sign-in?error=SessionInvalid straight after a successful 2FA (rigpert 60743, live on :4797 with CLIENT_ID=idp-admin-ui-graft
22
+ * against clientSlug idp_admin_ui_graft). FAIL CLOSED: refuse rather than run with two names. No IdP slug -> nothing to compare.
23
+ */
24
+ export declare function slugMismatchReason(idpClientSlug: string | null | undefined): string | null;
25
+ /**
26
+ * rigpert 60813: when the IdP returns NO client slug for this app (an unknown client: the config fetch failed or came back
27
+ * without one), startup must fail LOUD. Silently defaulting would run with a cookie name nobody verified against the IdP. The
28
+ * startup path swallows a failed config fetch when BETTER_AUTH_SECRET is supplied by env, so without this an unregistered
29
+ * CLIENT_ID (e.g. payez_idp_customer_web, absent on dev-93) would boot 'fine'.
30
+ */
31
+ export declare function assertIdpSlugKnown(idpClientSlug: string | null | undefined): void;
32
+ /**
33
+ * The health-endpoint view: a mismatch is always reportable; an UNKNOWN slug is reported only once startup has actually been
34
+ * attempted (before that nothing is known yet and health must not go red on a pod that has not started initialising).
35
+ */
36
+ export declare function slugStateReason(idpClientSlug: string | null | undefined, startupAttempted: boolean): string | null;
37
+ export declare function assertAppSlugMatchesIdp(idpClientSlug: string | null | undefined): void;
17
38
  /**
18
39
  * Clears the cached slug (useful for testing).
19
40
  */
@@ -12,6 +12,10 @@
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.getAppSlug = getAppSlug;
15
+ exports.slugMismatchReason = slugMismatchReason;
16
+ exports.assertIdpSlugKnown = assertIdpSlugKnown;
17
+ exports.slugStateReason = slugStateReason;
18
+ exports.assertAppSlugMatchesIdp = assertAppSlugMatchesIdp;
15
19
  exports.clearSlugCache = clearSlugCache;
16
20
  exports.getSessionPrefix = getSessionPrefix;
17
21
  exports.getAnonPrefix = getAnonPrefix;
@@ -37,6 +41,53 @@ function getAppSlug() {
37
41
  cachedSlug = process.env.APP_SLUG || process.env.CLIENT_ID || 'payez';
38
42
  return cachedSlug;
39
43
  }
44
+ /**
45
+ * The session cookie name has TWO producers: better-auth mints `${idpConfig.clientSlug || getAppSlug()}.session-token`, while
46
+ * the gate and every reader use getSessionCookieName() = getAppSlug() = APP_SLUG || CLIENT_ID. When the IdP's slug is known and
47
+ * differs, the gate looks for a cookie that was never set and every better-auth (Google) session bounces to
48
+ * /sign-in?error=SessionInvalid straight after a successful 2FA (rigpert 60743, live on :4797 with CLIENT_ID=idp-admin-ui-graft
49
+ * against clientSlug idp_admin_ui_graft). FAIL CLOSED: refuse rather than run with two names. No IdP slug -> nothing to compare.
50
+ */
51
+ function slugMismatchReason(idpClientSlug) {
52
+ if (!idpClientSlug)
53
+ return null;
54
+ const gateSlug = getAppSlug();
55
+ return idpClientSlug !== gateSlug ? `SLUG_MISMATCH: cookie=${idpClientSlug} gate=${gateSlug}` : null;
56
+ }
57
+ /**
58
+ * rigpert 60813: when the IdP returns NO client slug for this app (an unknown client: the config fetch failed or came back
59
+ * without one), startup must fail LOUD. Silently defaulting would run with a cookie name nobody verified against the IdP. The
60
+ * startup path swallows a failed config fetch when BETTER_AUTH_SECRET is supplied by env, so without this an unregistered
61
+ * CLIENT_ID (e.g. payez_idp_customer_web, absent on dev-93) would boot 'fine'.
62
+ */
63
+ function assertIdpSlugKnown(idpClientSlug) {
64
+ if (idpClientSlug)
65
+ return;
66
+ throw new Error(`[FATAL] The IdP returned no client slug for this app (gate slug "${getAppSlug()}"). Either CLIENT_ID/APP_SLUG names a client ` +
67
+ `the IdP does not know, or its config could not be fetched. Refusing to start with an unverified session cookie name.`);
68
+ }
69
+ /**
70
+ * The health-endpoint view: a mismatch is always reportable; an UNKNOWN slug is reported only once startup has actually been
71
+ * attempted (before that nothing is known yet and health must not go red on a pod that has not started initialising).
72
+ */
73
+ function slugStateReason(idpClientSlug, startupAttempted) {
74
+ const mismatch = slugMismatchReason(idpClientSlug);
75
+ if (mismatch)
76
+ return mismatch;
77
+ if (startupAttempted && !idpClientSlug)
78
+ return `SLUG_UNKNOWN: the IdP returned no client slug for this app gate=${getAppSlug()}`;
79
+ return null;
80
+ }
81
+ function assertAppSlugMatchesIdp(idpClientSlug) {
82
+ if (!idpClientSlug)
83
+ return;
84
+ const gateSlug = getAppSlug();
85
+ if (slugMismatchReason(idpClientSlug) !== null) {
86
+ throw new Error(`[FATAL] Session cookie name would have two sources: the IdP client slug is "${idpClientSlug}" but this app's slug ` +
87
+ `(APP_SLUG, else CLIENT_ID) is "${gateSlug}". better-auth would set "${idpClientSlug}.session-token" while the gate reads ` +
88
+ `"${gateSlug}.session-token", so every better-auth session would be rejected. Set APP_SLUG (or CLIENT_ID) to "${idpClientSlug}".`);
89
+ }
90
+ }
40
91
  /**
41
92
  * Clears the cached slug (useful for testing).
42
93
  */
@@ -17,6 +17,32 @@ export type EnsureFreshResult = {
17
17
  accessTokenExpires: number;
18
18
  /** True if we refreshed (or a concurrent refresh completed); false if the stored token was already fresh. */
19
19
  refreshed: boolean;
20
+ /**
21
+ * CARD 320072. Is the token in THIS result actually in the session store?
22
+ *
23
+ * ON THIS BRANCH OF THE UNION IT IS ALWAYS `true`, AND THAT IS THE POINT.
24
+ * Every `ok: true` return either read the token back from the store or
25
+ * wrote it and confirmed the write. An unpersisted refresh does not reach
26
+ * an `ok: true` result at all — it returns `ok:false, SESSION_PERSIST_FAILED`
27
+ * from the other branch below.
28
+ *
29
+ * READ THIS BEFORE YOU DELETE THE FIELD. It is declared `boolean` rather
30
+ * than the literal `true` on purpose: it is the assertable witness that the
31
+ * escalation is still wired. If someone later re-weakens the persist site
32
+ * back to reporting success, this field is where that regression becomes
33
+ * visible to a test instead of invisible to everyone. Cases 2 and 7 in
34
+ * test/ensure-fresh-persist-failure.test.cjs assert on it.
35
+ *
36
+ * WHAT THIS COMMENT USED TO SAY, AND WHY IT IS GONE. It documented `false`
37
+ * as a live value on an ok:true result — "we hold a valid token that was
38
+ * not persisted" — because that WAS the contract before this merge, when
39
+ * the dropped write was reported as a success with a flag on it. Blending
40
+ * in the escalation made that sentence describe a state the code can no
41
+ * longer produce. A docblock asserting an impossible semantic is exactly
42
+ * the defect f5ac6dc3 was written to fix, one file over; reproducing it
43
+ * here while merging that fix would be absurd.
44
+ */
45
+ persisted: boolean;
20
46
  } | {
21
47
  ok: false;
22
48
  code: string;
@@ -66,7 +66,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
66
66
  const effectiveExpires = jwtExpires > 0 ? Math.min(redisExpires || jwtExpires, jwtExpires) : redisExpires;
67
67
  const msUntilExpiry = effectiveExpires - now;
68
68
  if (msUntilExpiry > safetyWindowMs) {
69
- return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false };
69
+ // Durable by construction: this token came OUT of the store and we wrote nothing.
70
+ return { ok: true, accessToken: storedToken, accessTokenExpires: effectiveExpires, refreshed: false, persisted: true };
70
71
  }
71
72
  if (!session.idpRefreshToken) {
72
73
  return {
@@ -103,6 +104,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
103
104
  accessToken: after.idpAccessToken,
104
105
  accessTokenExpires: after.idpAccessTokenExpires,
105
106
  refreshed: true,
107
+ // Read back from the store after the winner's refresh - durable.
108
+ persisted: true,
106
109
  };
107
110
  }
108
111
  }
@@ -140,6 +143,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
140
143
  accessToken: latest.idpAccessToken,
141
144
  accessTokenExpires: latest.idpAccessTokenExpires,
142
145
  refreshed: true,
146
+ // Post-lock re-read: another caller already refreshed and stored this.
147
+ persisted: true,
143
148
  };
144
149
  }
145
150
  }
@@ -229,12 +234,37 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
229
234
  // between pre-lock read and our send would be misjudged.
230
235
  const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
231
236
  if (tokenUnchanged) {
232
- await (0, session_store_1.updateSession)(sessionToken, {
237
+ /**
238
+ * 320072: this is the OTHER updateSession call in this file, and it
239
+ * does NOT escalate on a null return the way the persist site below
240
+ * does. Checked, not overlooked: reaching here requires
241
+ * `tokenUnchanged`, which requires `postRefresh?.idpRefreshToken` to
242
+ * equal the token we sent — so the CAS read three lines up already
243
+ * proved the row exists. A missing row makes tokenUnchanged false and
244
+ * routes to the else branch instead, so the dropped-write path is
245
+ * very nearly unreachable here.
246
+ *
247
+ * "Very nearly" is why the return IS still captured. The residual both
248
+ * halves of this merge identified is real: the row can expire between
249
+ * that CAS read and this write, in which case the dead refresh token is
250
+ * NOT cleared and the "we discarded it" claim in the error below is
251
+ * false. That costs nothing to observe and cannot be inferred from any
252
+ * other line, so it is warned rather than assumed — see `if (!cleared)`
253
+ * immediately below. The RESULT is unchanged either way: the session is
254
+ * unusable and we return the same non-ok payload.
255
+ */
256
+ const cleared = await (0, session_store_1.updateSession)(sessionToken, {
233
257
  idpRefreshToken: '',
234
258
  idpRefreshTokenExpires: undefined,
235
259
  refreshTokenClearedAt: Date.now(),
236
260
  refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
237
261
  });
262
+ if (!cleared) {
263
+ console.warn('[ENSURE-FRESH] discard-token clear did not persist - no canonical session row', {
264
+ code,
265
+ requestId,
266
+ });
267
+ }
238
268
  }
239
269
  else {
240
270
  // Concurrent refresh already rotated the token — our 401 is stale. Return the
@@ -247,6 +277,8 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
247
277
  accessToken: postRefresh.idpAccessToken,
248
278
  accessTokenExpires: postRefresh.idpAccessTokenExpires,
249
279
  refreshed: true,
280
+ // `postRefresh` is a store read, so this token is by definition stored.
281
+ persisted: true,
250
282
  };
251
283
  }
252
284
  }
@@ -301,7 +333,14 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
301
333
  const acrLevel = String(decoded?.acr || session.authenticationLevel || '1');
302
334
  const hasNewRefresh = typeof newRefresh === 'string' && newRefresh.length > 0;
303
335
  const newKid = (0, token_utils_1.extractKidFromToken)(newAccess);
304
- await (0, session_store_1.updateSession)(sessionToken, {
336
+ // *** CARD 320072. CAPTURE THIS RETURN. ***
337
+ // `updateSession` returns null - and writes nothing - when the canonical
338
+ // session row is missing, which happens whenever the BA fallback above is what
339
+ // resolved this session. Discarding the return is what let this function
340
+ // report `ok:true, refreshed:true` for a refresh it had just thrown away.
341
+ // The full rationale for what we DO about it is at the `if (!persisted)`
342
+ // block below; this line only makes the answer available.
343
+ const persisted = await (0, session_store_1.updateSession)(sessionToken, {
305
344
  ...session,
306
345
  idpAccessToken: newAccess,
307
346
  idpAccessTokenExpires: accessTokenExpires,
@@ -320,7 +359,69 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
320
359
  mfaExpiresAt: decoded?.mfa_expires ? parseInt(decoded.mfa_expires) * 1000 : session.mfaExpiresAt,
321
360
  mfaValidityHours: decoded?.mfa_validity_hours ? parseInt(decoded.mfa_validity_hours) : session.mfaValidityHours,
322
361
  });
323
- return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true };
362
+ /**
363
+ * 320072: DO NOT REPORT SUCCESS FOR A WRITE THAT WAS DISCARDED.
364
+ *
365
+ * `updateSession` returns null when the canonical session row is absent,
366
+ * which silently throws away EVERYTHING above — the new access token, the
367
+ * rotated refresh token, decodedAccessToken, authenticationMethods,
368
+ * mfaVerified, mfaCompletedAt, mfaExpiresAt. This return value was
369
+ * previously ignored, and the line below then reported
370
+ * `ok: true, refreshed: true`.
371
+ *
372
+ * WHY THAT IS WORSE THAN A PLAIN LOST WRITE. By this point the exchange at
373
+ * the IDP has ALREADY HAPPENED, so if refresh tokens are single-use the
374
+ * token we sent is now spent and its replacement is the thing we just
375
+ * dropped. The stored credential is therefore dead, and every later attempt
376
+ * re-sends that same dead token and fails. The user is bounced to LOGIN —
377
+ * not to verify-code — with nothing in the logs naming the cause.
378
+ *
379
+ * It did not even surface as a wrong answer to our one caller: server/auth.ts
380
+ * calls getRedisSession immediately after us, which is this same `getSession`,
381
+ * so it finds the same missing row and returns a generic NO_SESSION one frame
382
+ * later. The false `ok: true` was thus invisible AND the real cause — a
383
+ * dropped persist — was reported as an unrelated missing session.
384
+ *
385
+ * Naming it here is the point: the failure is reported at its true cause,
386
+ * with a code that says the refresh succeeded and the PERSIST is what
387
+ * failed. Not retryable — retrying burns another refresh token the same way.
388
+ *
389
+ * This is the card's option (b), deliberately. Option (a) — teaching
390
+ * updateSession to write through to whichever store holds the session,
391
+ * mirroring the read fallback above — is the better end state but changes
392
+ * write-path storage semantics for every caller, which is not this card.
393
+ *
394
+ * AND IT IS ALSO LOGGED, WHICH IS NOT THE SAME THING (BAPert R1134 §7,
395
+ * re-affirmed in R1145 §2c). Returning a named code tells the CALLER. §7
396
+ * requires that `persisted:false` reach a log A HUMAN READS, because the one
397
+ * caller — server/auth.ts — converts every non-ok result into the same
398
+ * generic 401 and keeps no record of which code produced it. Without this
399
+ * line the distinction we just bought at the type level is erased one frame
400
+ * later and no operator ever sees the word PERSIST. The escalation is the
401
+ * contract; the warn is the evidence.
402
+ */
403
+ if (!persisted) {
404
+ console.warn('[ENSURE-FRESH] refresh succeeded but was NOT persisted - no canonical session row', {
405
+ requestId,
406
+ refreshTokenRotated: hasNewRefresh,
407
+ persisted: false,
408
+ });
409
+ return {
410
+ ok: false,
411
+ code: 'SESSION_PERSIST_FAILED',
412
+ message: 'Token refresh succeeded at the IDP but the session could not be persisted; the rotated tokens were discarded.',
413
+ status: 401,
414
+ terminal: true,
415
+ retryable: false,
416
+ discardToken: true,
417
+ resolution: 'REAUTHENTICATE',
418
+ };
419
+ }
420
+ // `persisted: true` is not decoration and not a leftover: the `if` above is
421
+ // the only way out for a dropped write, so by construction anything reaching
422
+ // this line WAS written. Keeping the field keeps the ok:true branch of
423
+ // EnsureFreshResult total (Cases 2 and 7 assert on it) — BAPert R1145 §2d.
424
+ return { ok: true, accessToken: newAccess, accessTokenExpires, refreshed: true, persisted: true };
324
425
  }
325
426
  finally {
326
427
  if (weHoldLock) {
@@ -81,6 +81,26 @@ export interface IDPClientConfig {
81
81
  branding: BrandingConfig;
82
82
  baseClientUrl?: string;
83
83
  }
84
+ /**
85
+ * The IdP serves the signing secret and every OAuth provider client secret in the client config, to any
86
+ * caller holding a client assertion (PAY-1460). It cannot stop serving them while this package depends on
87
+ * them: every earlier version throws FATAL on an empty config authSecret, whatever the environment says.
88
+ *
89
+ * So both are now resolved ENV FIRST, config second:
90
+ * signing secret BETTER_AUTH_SECRET (canonical), NEXTAUTH_SECRET (legacy alias), then config authSecret.
91
+ * Uses the module-load snapshot above: this module publishes the config value into
92
+ * process.env, so a live read would mistake the IdP's value for the operator's.
93
+ * provider secret <PROVIDER>_CLIENT_SECRET, provider name upper-cased with non-alphanumerics as '_'
94
+ * (GOOGLE_CLIENT_SECRET, APPLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET), then config.
95
+ *
96
+ * The FATAL on an empty signing secret is unchanged and still load-bearing: it now fires only when BOTH the
97
+ * environment and the config are empty.
98
+ *
99
+ * Values are never logged. The SOURCE of each is logged once per change. An env-sourced value is never
100
+ * written to the Redis config cache (stripEnvSecrets); a cache read re-applies the environment.
101
+ */
102
+ export type SecretSource = string;
103
+ export declare function providerSecretEnvVar(provider: string): string;
84
104
  /**
85
105
  * Get IDP client configuration with multi-tier caching.
86
106
  *