@payez/next-mvp 4.4.5 → 4.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) 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 +6 -3
  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 +14 -0
  57. package/dist/auth/better-auth.js +35 -49
  58. package/dist/auth/utils/idp-client.js +2 -1
  59. package/dist/components/admin/DataBrowserTab.js +4 -1
  60. package/dist/components/admin/SessionsTab.js +9 -3
  61. package/dist/components/recovery/RequestedStep.d.ts +11 -0
  62. package/dist/components/recovery/RequestedStep.js +14 -0
  63. package/dist/components/recovery/SetPasswordStep.js +10 -1
  64. package/dist/lib/app-slug.d.ts +21 -0
  65. package/dist/lib/app-slug.js +51 -0
  66. package/dist/lib/ensure-fresh-access-token.d.ts +26 -0
  67. package/dist/lib/ensure-fresh-access-token.js +105 -4
  68. package/dist/lib/idp-client-config.d.ts +20 -0
  69. package/dist/lib/idp-client-config.js +66 -45
  70. package/dist/lib/idp-fetch.js +1 -1
  71. package/dist/lib/password-rules.d.ts +22 -0
  72. package/dist/lib/password-rules.js +43 -0
  73. package/dist/lib/safe-error-response.d.ts +67 -0
  74. package/dist/lib/safe-error-response.js +130 -0
  75. package/dist/lib/session-cookie-shape.d.ts +22 -19
  76. package/dist/lib/session-cookie-shape.js +14 -18
  77. package/dist/lib/session-store.d.ts +8 -1
  78. package/dist/lib/session-store.js +170 -24
  79. package/dist/lib/startup-init.d.ts +6 -0
  80. package/dist/lib/startup-init.js +30 -4
  81. package/dist/lib/token-lifecycle.js +2 -1
  82. package/dist/lib/token-log-id.d.ts +18 -0
  83. package/dist/lib/token-log-id.js +84 -0
  84. package/dist/lib/two-factor-verify-failure.d.ts +45 -0
  85. package/dist/lib/two-factor-verify-failure.js +143 -0
  86. package/dist/middleware/create-middleware.d.ts +2 -1
  87. package/dist/middleware/create-middleware.js +43 -8
  88. package/dist/pages/recovery/page.js +14 -4
  89. package/dist/pages/test-env/JwtInspectPage.js +7 -149
  90. package/dist/pages/test-env/RefreshTokenPage.js +4 -1
  91. package/dist/pages/test-env/redact-credentials.d.ts +6 -0
  92. package/dist/pages/test-env/redact-credentials.js +101 -0
  93. package/dist/pages/verify-code/page.js +2 -1
  94. package/dist/routes/auth/logout.d.ts +2 -1
  95. package/dist/routes/auth/logout.js +4 -2
  96. package/dist/routes/auth/session.d.ts +0 -3
  97. package/dist/routes/auth/session.js +16 -10
  98. package/dist/routes/auth/viability.d.ts +3 -2
  99. package/dist/routes/auth/viability.js +11 -7
  100. package/dist/server/auth.js +10 -6
  101. package/dist/server/decode-session.js +15 -26
  102. package/dist/server/verified-session-token.d.ts +13 -0
  103. package/dist/server/verified-session-token.js +103 -0
  104. package/dist/stores/authStore.js +14 -3
  105. package/dist/types/recovery.d.ts +1 -1
  106. package/dist/utils/api.d.ts +18 -0
  107. package/dist/utils/api.js +51 -9
  108. package/dist/vibe/sessions.js +2 -1
  109. package/package.json +2 -1
  110. package/scripts/verify-release-provenance.mjs +410 -0
  111. package/src/api/auth-handler.ts +5 -3
  112. package/src/api-handlers/account/change-password.ts +6 -3
  113. package/src/api-handlers/account/masked-info.ts +2 -1
  114. package/src/api-handlers/account/recovery/forward.ts +117 -0
  115. package/src/api-handlers/account/recovery/initiate.ts +3 -0
  116. package/src/api-handlers/account/recovery/send-code.ts +13 -99
  117. package/src/api-handlers/account/recovery/verify-code.ts +12 -99
  118. package/src/api-handlers/account/reset-password.ts +30 -1
  119. package/src/api-handlers/account/send-code.ts +3 -9
  120. package/src/api-handlers/account/update-phone.ts +6 -1
  121. package/src/api-handlers/account/validate-password.ts +3 -1
  122. package/src/api-handlers/account/verify-email.ts +4 -9
  123. package/src/api-handlers/account/verify-sms.ts +4 -9
  124. package/src/api-handlers/admin/analytics.ts +13 -5
  125. package/src/api-handlers/admin/audit.ts +13 -5
  126. package/src/api-handlers/admin/redis-sessions.ts +16 -8
  127. package/src/api-handlers/admin/sessions.ts +15 -7
  128. package/src/api-handlers/admin/site-logs.ts +39 -16
  129. package/src/api-handlers/admin/stats.ts +15 -5
  130. package/src/api-handlers/admin/users.ts +15 -7
  131. package/src/api-handlers/admin/vibe-data.ts +20 -12
  132. package/src/api-handlers/auth/jwks.ts +2 -1
  133. package/src/api-handlers/auth/login.ts +13 -4
  134. package/src/api-handlers/auth/passkey-signin.ts +4 -0
  135. package/src/api-handlers/auth/refresh.ts +38 -11
  136. package/src/api-handlers/auth/signout.ts +2 -1
  137. package/src/api-handlers/auth/status.ts +4 -1
  138. package/src/api-handlers/auth/update-session.ts +45 -11
  139. package/src/api-handlers/auth/validate.ts +4 -2
  140. package/src/api-handlers/auth/verify-code.ts +3 -2
  141. package/src/api-handlers/mfa/backup-code-verify.ts +4 -9
  142. package/src/api-handlers/mfa/backup-codes.ts +2 -1
  143. package/src/api-handlers/mfa/methods.ts +2 -1
  144. package/src/api-handlers/mfa/totp-confirm.ts +2 -1
  145. package/src/api-handlers/mfa/totp-enroll.ts +2 -1
  146. package/src/api-handlers/mfa/totp-verify.ts +4 -9
  147. package/src/api-handlers/passkeys/list.ts +2 -1
  148. package/src/api-handlers/passkeys/manage.ts +2 -1
  149. package/src/api-handlers/passkeys/register-options.ts +2 -1
  150. package/src/api-handlers/passkeys/register-verify.ts +2 -1
  151. package/src/api-handlers/session/refresh-viability.ts +10 -7
  152. package/src/api-handlers/session/viability.ts +4 -3
  153. package/src/api-handlers/test/force-expire.ts +5 -3
  154. package/src/auth/auth-decision.ts +2 -1
  155. package/src/auth/better-auth.ts +37 -55
  156. package/src/auth/utils/idp-client.ts +2 -1
  157. package/src/components/admin/DataBrowserTab.tsx +4 -1
  158. package/src/components/admin/SessionsTab.tsx +10 -3
  159. package/src/components/recovery/RequestedStep.tsx +30 -0
  160. package/src/components/recovery/SetPasswordStep.tsx +24 -3
  161. package/src/hooks/useViabilitySession.ts +2 -1
  162. package/src/lib/app-slug.ts +50 -0
  163. package/src/lib/ensure-fresh-access-token.ts +133 -4
  164. package/src/lib/idp-client-config.ts +91 -45
  165. package/src/lib/idp-fetch.ts +1 -1
  166. package/src/lib/password-rules.ts +50 -0
  167. package/src/lib/safe-error-response.ts +126 -0
  168. package/src/lib/session-cookie-shape.ts +23 -22
  169. package/src/lib/session-store.ts +167 -24
  170. package/src/lib/startup-init.ts +29 -4
  171. package/src/lib/token-lifecycle.ts +2 -1
  172. package/src/lib/token-log-id.ts +69 -0
  173. package/src/lib/two-factor-verify-failure.ts +167 -0
  174. package/src/middleware/create-middleware.ts +55 -9
  175. package/src/pages/recovery/page.tsx +21 -4
  176. package/src/pages/test-env/JwtInspectPage.tsx +11 -233
  177. package/src/pages/test-env/RefreshTokenPage.tsx +9 -4
  178. package/src/pages/test-env/redact-credentials.ts +96 -0
  179. package/src/pages/verify-code/page.tsx +2 -1
  180. package/src/routes/auth/logout.ts +4 -2
  181. package/src/routes/auth/session.ts +16 -10
  182. package/src/routes/auth/viability.ts +11 -7
  183. package/src/server/auth.ts +10 -6
  184. package/src/server/decode-session.ts +16 -26
  185. package/src/server/verified-session-token.ts +103 -0
  186. package/src/stores/authStore.ts +16 -5
  187. package/src/types/recovery.ts +1 -1
  188. package/src/utils/api.ts +47 -8
  189. package/src/vibe/sessions.ts +2 -1
package/README.md CHANGED
@@ -145,9 +145,16 @@ IDP_URL=https://idp.payez.net # or http://localhost:32785 for local d
145
145
  # NextAuth trusts request headers for OAuth callback URLs
146
146
  AUTH_TRUST_HOST=true
147
147
 
148
- # NEXTAUTH_SECRET - DO NOT SET!
149
- # The MVP broker fetches this securely from IDP at startup.
150
- # Only set manually if you need to override the IDP-provided secret.
148
+ # Secrets are resolved ENV FIRST, IdP client config second (PAY-1460, 4.4.6+).
149
+ # The IdP is retiring the secrets it serves in client config, so production
150
+ # deployments should set these from a k8s secret:
151
+ # BETTER_AUTH_SECRET=... # session signing secret (canonical name)
152
+ # NEXTAUTH_SECRET=... # legacy alias, used only if BETTER_AUTH_SECRET is unset
153
+ # GOOGLE_CLIENT_SECRET=... # per OAuth provider: <PROVIDER>_CLIENT_SECRET,
154
+ # APPLE_CLIENT_SECRET=... # provider name upper-cased, non-alphanumerics as "_"
155
+ # Startup is FATAL only when BOTH the env and the IdP config lack a signing secret.
156
+ # Values are never logged; the log names the source ("authSecret source: env:BETTER_AUTH_SECRET").
157
+ # Env-sourced secrets are never written to the Redis config cache.
151
158
 
152
159
  # Optional
153
160
  REDIS_URL=redis://localhost:6379 # For session storage
@@ -19,6 +19,7 @@ const server_1 = require("next/server");
19
19
  const auth_1 = require("../server/auth");
20
20
  const nanoid_1 = require("nanoid");
21
21
  const session_store_1 = require("../lib/session-store");
22
+ const token_log_id_1 = require("../lib/token-log-id");
22
23
  /**
23
24
  * Creates an auth-aware handler with automatic token refresh
24
25
  *
@@ -179,7 +180,7 @@ function createAuthHandler(options = {}) {
179
180
  refreshRequestBody.two_factor_completed_at = new Date(currentSession.mfaCompletedAt).toISOString();
180
181
  }
181
182
  console.info('[AUTH_HANDLER] Executing refresh against IDP', {
182
- sessionToken: sessionToken.substring(0, 8) + '...',
183
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
183
184
  hasRefreshToken: true
184
185
  });
185
186
  const response = await fetch(`${idpBaseUrl}/api/ExternalAuth/refresh`, {
@@ -192,7 +193,8 @@ function createAuthHandler(options = {}) {
192
193
  });
193
194
  if (!response.ok) {
194
195
  const errorText = await response.text().catch(() => 'Unknown error');
195
- console.error('[AUTH_HANDLER] Refresh failed:', response.status, errorText);
196
+ // PAY-1427: never the raw upstream body (the refresh.ts:528 twin PR 139 fixed). Status + length only.
197
+ console.error('[AUTH_HANDLER] Refresh failed:', { status: response.status, bodyLength: errorText.length });
196
198
  return { success: false, error: `Refresh failed: ${response.status}` };
197
199
  }
198
200
  const data = await response.json();
@@ -317,7 +319,7 @@ function createAuthHandler(options = {}) {
317
319
  const requestId = generateRequestId();
318
320
  console.info('[AUTH_HANDLER] Token near expiry, initiating coordinated refresh', {
319
321
  requestId,
320
- sessionToken: authContext.sessionToken.substring(0, 8) + '...'
322
+ sessionToken: (0, token_log_id_1.tokenLogId)(authContext.sessionToken)
321
323
  });
322
324
  const refreshResult = await performCoordinatedRefresh(authContext.sessionToken, requestId);
323
325
  if (refreshResult.success && refreshResult.accessToken) {
@@ -5,6 +5,7 @@ const server_1 = require("next/server");
5
5
  const auth_1 = require("../../server/auth");
6
6
  const session_store_1 = require("../../lib/session-store");
7
7
  const nanoid_1 = require("nanoid");
8
+ const safe_error_response_1 = require("../../lib/safe-error-response");
8
9
  // ...
9
10
  async function POST(req) {
10
11
  const requestId = (0, nanoid_1.nanoid)();
@@ -88,7 +89,8 @@ async function POST(req) {
88
89
  message: errorMessage,
89
90
  error_code: responseData.error_code || 'CHANGE_PASSWORD_FAILED',
90
91
  request_id: requestId,
91
- details: responseData.details,
92
+ // PAY-1438: only the password-policy messages the security pages display, never the raw envelope.
93
+ details: (0, safe_error_response_1.projectPasswordPolicyDetails)(responseData.details),
92
94
  }, { status: idpResponse.status });
93
95
  }
94
96
  return server_1.NextResponse.json({
@@ -98,11 +100,12 @@ async function POST(req) {
98
100
  });
99
101
  }
100
102
  catch (error) {
101
- console.error('[CHANGE_PASSWORD] Error:', error);
102
103
  const requestId = req.headers.get('x-request-id') ?? crypto.randomUUID();
104
+ // PAY-1438: the exception text stays in the log, keyed by the request_id the caller receives.
105
+ console.error('[CHANGE_PASSWORD] Error:', { request_id: requestId, error });
103
106
  return server_1.NextResponse.json({
104
107
  success: false,
105
- message: error instanceof Error ? error.message : 'Failed to change password',
108
+ message: 'Failed to change password',
106
109
  error_code: 'INTERNAL_ERROR',
107
110
  request_id: requestId,
108
111
  }, { status: 500 });
@@ -4,6 +4,7 @@ exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
5
  const idp_fetch_1 = require("../../lib/idp-fetch");
6
6
  const env_1 = require("../../config/env");
7
+ const safe_error_response_1 = require("../../lib/safe-error-response");
7
8
  // IDP masked-info is POST and uses capital 'A' in /api/Account
8
9
  async function POST(req) {
9
10
  const url = `${env_1.ENV_CONFIG.IDP_URL}/api/Account/masked-info`;
@@ -24,7 +25,7 @@ async function POST(req) {
24
25
  return server_1.NextResponse.json({
25
26
  success: false,
26
27
  message: 'Upstream error',
27
- error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: result.json },
28
+ error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: (0, safe_error_response_1.projectIdpError)(result.json) },
28
29
  meta: { attemptedRefresh: result.attemptedRefresh },
29
30
  }, { status: result.status });
30
31
  }
@@ -0,0 +1,14 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ /** The recovery bearer from the inbound request, or null when absent / malformed. Never throws. */
3
+ export declare function inboundRecoveryBearer(req: Pick<NextRequest, 'headers'>): string | null;
4
+ export interface RecoveryForward {
5
+ /** IdP path after the host, e.g. /api/Account/recovery/send-code */
6
+ upstreamPath: string;
7
+ /** log/route label used for the client-key resolver */
8
+ where: string;
9
+ /** stable `error` code for an upstream that could not be reached */
10
+ failureCode: string;
11
+ /** user-facing fallback when the IdP gave no message */
12
+ fallbackMessage: string;
13
+ }
14
+ export declare function forwardRecoveryCall(req: NextRequest, spec: RecoveryForward): Promise<NextResponse>;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inboundRecoveryBearer = inboundRecoveryBearer;
4
+ exports.forwardRecoveryCall = forwardRecoveryCall;
5
+ const server_1 = require("next/server");
6
+ const env_1 = require("../../../config/env");
7
+ const idp_client_config_1 = require("../../../lib/idp-client-config");
8
+ const client_key_1 = require("../../../lib/client-key");
9
+ const safe_error_response_1 = require("../../../lib/safe-error-response");
10
+ /**
11
+ * Account recovery - the ONE forwarder behind /recovery/send-code and /recovery/verify-code (PAY-1588).
12
+ *
13
+ * THE DEFECT (QAPert, W1 walk on :4797 -> dev-93 IdP 90f766bd0, 2026-09-19): portal /account-auth/recovery,
14
+ * initiate -> 200, send-code -> 401 "Upstream error". The browser sends `Authorization: Bearer <recovery session
15
+ * token>` (the token `initiate` minted); these handlers went through idpFetchJSON, which sets Authorization ONLY from
16
+ * the server-side SESSION and never forwards the inbound header, so the IdP got no Bearer at all
17
+ * (AccountController.cs:872-885 = 401 MISSING_TOKEN). Positive control direct to the IdP: without Authorization 401,
18
+ * with it 200 and the email is sent.
19
+ *
20
+ * WHY THE 2026-08-02 REWRITE GOT IT WRONG. It reasoned "browsers send a session COOKIE and no Authorization header"
21
+ * and moved both handlers onto idpFetchJSON. That is true of every OTHER authenticated route. It is false of this
22
+ * flow: the caller is LOCKED OUT, so there is no session to resolve - the recovery session token IS the credential,
23
+ * and the browser is the only party that holds it. Resolving a token from a session that does not exist is why the
24
+ * entire recovery path, both halves, has been dead: it fails exactly when it is the only thing left that could help.
25
+ *
26
+ * WHAT THIS DOES INSTEAD
27
+ * - Forwards the inbound `Authorization: Bearer <token>` to the IdP and nothing else from the caller's headers.
28
+ * - Uses a direct fetch, NOT idpFetchJSON: a stray session belonging to someone else on the same browser must not
29
+ * be able to override the recovery credential with its own bearer (idpFetchJSON's session bearer wins over any
30
+ * caller-supplied Authorization).
31
+ * - FAILS LOUD when there is no usable bearer: 401 `missing_recovery_token`, without calling upstream. The old
32
+ * behaviour returned the IdP's opaque rejection, so the user saw recovery "not working" and the log showed an
33
+ * upstream error rather than the auth fault it was.
34
+ * - Shape-checks the token (a bearer scheme, one opaque token, sane length) before forwarding, so the header cannot
35
+ * be used to smuggle anything but a credential to the IdP. It is never logged.
36
+ * - Anonymous route (PAY-1438): on failure only the whitelisted envelope, never the IdP body.
37
+ *
38
+ * SERVER VALUE WINS for client_id (cards 268012 / 268410): applied AFTER the caller's payload spread, because these are
39
+ * unauthenticated endpoints and a browser-supplied client_id would be a tenant-targeting lever on a password-reset path.
40
+ *
41
+ * @package @payez/next-mvp
42
+ */
43
+ /** One opaque token: JWT / base64url / opaque-hex alphabet, no whitespace or control characters, bounded length. */
44
+ const TOKEN_SHAPE = /^[A-Za-z0-9\-._~+/]+=*$/;
45
+ /** The recovery bearer from the inbound request, or null when absent / malformed. Never throws. */
46
+ function inboundRecoveryBearer(req) {
47
+ const raw = req.headers.get('authorization');
48
+ if (!raw)
49
+ return null;
50
+ const m = /^Bearer[ ]+(\S+)$/i.exec(raw.trim());
51
+ if (!m)
52
+ return null;
53
+ const token = m[1];
54
+ if (token.length < 16 || token.length > 4096)
55
+ return null;
56
+ return TOKEN_SHAPE.test(token) ? token : null;
57
+ }
58
+ async function recoveryClientKey(where) {
59
+ try {
60
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
61
+ return (0, client_key_1.resolveClientKey)(config, where).key;
62
+ }
63
+ catch (e) {
64
+ console.error(`[${where}] could not resolve IDP client config for client_id:`, e);
65
+ return null;
66
+ }
67
+ }
68
+ async function forwardRecoveryCall(req, spec) {
69
+ let payload = {};
70
+ try {
71
+ payload = await req.json();
72
+ }
73
+ catch {
74
+ return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
75
+ }
76
+ const bearer = inboundRecoveryBearer(req);
77
+ if (!bearer) {
78
+ return server_1.NextResponse.json({
79
+ success: false,
80
+ error: 'missing_recovery_token',
81
+ message: 'Your recovery session could not be found. Start the recovery again from the beginning.',
82
+ }, { status: 401 });
83
+ }
84
+ const clientKey = await recoveryClientKey(spec.where);
85
+ let upstream;
86
+ try {
87
+ upstream = await fetch(`${env_1.ENV_CONFIG.IDP_URL}${spec.upstreamPath}`, {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ Accept: 'application/json',
92
+ Authorization: `Bearer ${bearer}`,
93
+ ...(clientKey ? { 'X-Client-Id': clientKey } : {}),
94
+ },
95
+ body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
96
+ });
97
+ }
98
+ catch {
99
+ return server_1.NextResponse.json({ success: false, error: spec.failureCode }, { status: 502 });
100
+ }
101
+ const data = await upstream.json().catch(() => ({}));
102
+ if (!upstream.ok)
103
+ return server_1.NextResponse.json((0, safe_error_response_1.idpErrorEnvelope)(data, spec.fallbackMessage), { status: upstream.status });
104
+ return server_1.NextResponse.json(data, { status: 200 });
105
+ }
@@ -4,6 +4,7 @@ exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
5
  const idp_client_config_1 = require("../../../lib/idp-client-config");
6
6
  const client_key_1 = require("../../../lib/client-key");
7
+ const safe_error_response_1 = require("../../../lib/safe-error-response");
7
8
  function getIdpUrl() {
8
9
  const url = process.env.IDP_URL;
9
10
  if (!url)
@@ -51,6 +52,9 @@ async function POST(req) {
51
52
  body: JSON.stringify({ email, ...(clientKey ? { client_id: clientKey } : {}) }),
52
53
  });
53
54
  const data = await upstream.json().catch(() => ({}));
55
+ // PAY-1438: anonymous route - on failure only the whitelisted envelope, never the IDP body.
56
+ if (!upstream.ok)
57
+ return server_1.NextResponse.json((0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to initiate recovery'), { status: upstream.status });
54
58
  return server_1.NextResponse.json(data, { status: upstream.status });
55
59
  }
56
60
  catch (e) {
@@ -1,2 +1,12 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- export declare function POST(req: NextRequest): Promise<NextResponse<any>>;
1
+ import { NextRequest } from 'next/server';
2
+ /**
3
+ * Account recovery - send-code.
4
+ *
5
+ * PAY-1588: the recovery session token minted by `initiate` is the caller's only credential (they are locked out; there is
6
+ * no session), so this forwards the inbound `Authorization: Bearer <token>` to the IdP and fails loud (401
7
+ * `missing_recovery_token`) when it is absent. The whole reasoning, and why the 2026-08-02 idpFetchJSON rewrite killed
8
+ * this flow, is in ./forward.ts. send-code and verify-code share that ONE forwarder so they cannot drift apart again.
9
+ *
10
+ * @package @payez/next-mvp
11
+ */
12
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -1,95 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.POST = POST;
4
- const server_1 = require("next/server");
5
- const idp_fetch_1 = require("../../../lib/idp-fetch");
6
- const env_1 = require("../../../config/env");
7
- const idp_client_config_1 = require("../../../lib/idp-client-config");
8
- const client_key_1 = require("../../../lib/client-key");
4
+ const forward_1 = require("./forward");
9
5
  /**
10
- * Account recovery — send-code
6
+ * Account recovery - send-code.
11
7
  *
12
- * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
13
- * This handler read the caller's bearer straight off the inbound request:
14
- *
15
- * const auth = req.headers.get('authorization') || '';
16
- * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
17
- * ... headers: { 'Authorization': `Bearer ${token}` }
18
- *
19
- * Browsers send a session COOKIE and no Authorization header, so `token` was
20
- * ALWAYS the empty string and this always called the IDP with a literal
21
- * "Bearer " and nothing after it.
22
- *
23
- * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
24
- *
25
- * 1. profile.ts at least 401'd when the header was missing. This one has no
26
- * check at all — `|| ''` swallows the absence — so it proceeds to call
27
- * upstream with an empty credential and returns the IDP's rejection as an
28
- * opaque failure. The user sees recovery "not working", not "not
29
- * authenticated", and the log shows an upstream error rather than an auth
30
- * one. The defect hides itself.
31
- * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
32
- * the entire recovery path is unreachable, not merely degraded. This is
33
- * the flow a locked-out user reaches for, i.e. it fails exactly when it is
34
- * the only thing left that could help them.
35
- *
36
- * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
37
- * the caller's token from the server-side session, so the cookie works and the
38
- * token is never handed to browser JavaScript.
8
+ * PAY-1588: the recovery session token minted by `initiate` is the caller's only credential (they are locked out; there is
9
+ * no session), so this forwards the inbound `Authorization: Bearer <token>` to the IdP and fails loud (401
10
+ * `missing_recovery_token`) when it is absent. The whole reasoning, and why the 2026-08-02 idpFetchJSON rewrite killed
11
+ * this flow, is in ./forward.ts. send-code and verify-code share that ONE forwarder so they cannot drift apart again.
39
12
  *
40
13
  * @package @payez/next-mvp
41
14
  */
42
- /**
43
- * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
44
- *
45
- * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
46
- * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
47
- * recovery token was ever minted. That is why S12 failed at step 1.
48
- *
49
- * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
50
- * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
51
- * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
52
- * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
53
- * added four more sites to the 268410 family and made a stale id answer for
54
- * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
55
- * the numeric fallback logged, is the same resolver the other nine sites use.
56
- *
57
- * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
58
- * These are unauthenticated recovery endpoints, so a browser-supplied
59
- * client_id would be a tenant-targeting lever. The caller does not get to
60
- * choose which tenant their password reset is addressed to.
61
- */
62
- async function recoveryClientKey() {
63
- try {
64
- const config = await (0, idp_client_config_1.getIDPClientConfig)();
65
- return (0, client_key_1.resolveClientKey)(config, 'account/recovery/send-code').key;
66
- }
67
- catch (e) {
68
- console.error('[account/recovery/send-code] could not resolve IDP client config for client_id:', e);
69
- return null;
70
- }
71
- }
72
15
  async function POST(req) {
73
- let payload = {};
74
- try {
75
- payload = await req.json();
76
- }
77
- catch {
78
- return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
79
- }
80
- const clientKey = await recoveryClientKey();
81
- const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/recovery/send-code`, {
82
- method: 'POST',
83
- headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
84
- body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
16
+ return (0, forward_1.forwardRecoveryCall)(req, {
17
+ upstreamPath: '/api/Account/recovery/send-code',
18
+ where: 'account/recovery/send-code',
19
+ failureCode: 'send_code_failed',
20
+ fallbackMessage: 'Failed to send the verification code',
85
21
  });
86
- if (!result.ok) {
87
- return server_1.NextResponse.json({
88
- success: false,
89
- error: 'send_code_failed',
90
- message: result.json?.message || 'Upstream error',
91
- meta: { status: result.status, attemptedRefresh: result.attemptedRefresh },
92
- }, { status: result.status });
93
- }
94
- return server_1.NextResponse.json(result.json, { status: 200 });
95
22
  }
@@ -1,2 +1,11 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- export declare function POST(req: NextRequest): Promise<NextResponse<any>>;
1
+ import { NextRequest } from 'next/server';
2
+ /**
3
+ * Account recovery - verify-code.
4
+ *
5
+ * PAY-1588: forwards the inbound recovery `Authorization: Bearer <token>` to the IdP and fails loud (401
6
+ * `missing_recovery_token`) when it is absent. See ./forward.ts for the full reasoning; send-code and verify-code share
7
+ * that ONE forwarder so they cannot drift apart again.
8
+ *
9
+ * @package @payez/next-mvp
10
+ */
11
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -1,95 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.POST = POST;
4
- const server_1 = require("next/server");
5
- const idp_fetch_1 = require("../../../lib/idp-fetch");
6
- const env_1 = require("../../../config/env");
7
- const idp_client_config_1 = require("../../../lib/idp-client-config");
8
- const client_key_1 = require("../../../lib/client-key");
4
+ const forward_1 = require("./forward");
9
5
  /**
10
- * Account recovery — verify-code
6
+ * Account recovery - verify-code.
11
7
  *
12
- * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
13
- * This handler read the caller's bearer straight off the inbound request:
14
- *
15
- * const auth = req.headers.get('authorization') || '';
16
- * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
17
- * ... headers: { 'Authorization': `Bearer ${token}` }
18
- *
19
- * Browsers send a session COOKIE and no Authorization header, so `token` was
20
- * ALWAYS the empty string and this always called the IDP with a literal
21
- * "Bearer " and nothing after it.
22
- *
23
- * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
24
- *
25
- * 1. profile.ts at least 401'd when the header was missing. This one has no
26
- * check at all — `|| ''` swallows the absence — so it proceeds to call
27
- * upstream with an empty credential and returns the IDP's rejection as an
28
- * opaque failure. The user sees recovery "not working", not "not
29
- * authenticated", and the log shows an upstream error rather than an auth
30
- * one. The defect hides itself.
31
- * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
32
- * the entire recovery path is unreachable, not merely degraded. This is
33
- * the flow a locked-out user reaches for, i.e. it fails exactly when it is
34
- * the only thing left that could help them.
35
- *
36
- * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
37
- * the caller's token from the server-side session, so the cookie works and the
38
- * token is never handed to browser JavaScript.
8
+ * PAY-1588: forwards the inbound recovery `Authorization: Bearer <token>` to the IdP and fails loud (401
9
+ * `missing_recovery_token`) when it is absent. See ./forward.ts for the full reasoning; send-code and verify-code share
10
+ * that ONE forwarder so they cannot drift apart again.
39
11
  *
40
12
  * @package @payez/next-mvp
41
13
  */
42
- /**
43
- * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
44
- *
45
- * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
46
- * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
47
- * recovery token was ever minted. That is why S12 failed at step 1.
48
- *
49
- * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
50
- * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
51
- * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
52
- * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
53
- * added four more sites to the 268410 family and made a stale id answer for
54
- * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
55
- * the numeric fallback logged, is the same resolver the other nine sites use.
56
- *
57
- * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
58
- * These are unauthenticated recovery endpoints, so a browser-supplied
59
- * client_id would be a tenant-targeting lever. The caller does not get to
60
- * choose which tenant their password reset is addressed to.
61
- */
62
- async function recoveryClientKey() {
63
- try {
64
- const config = await (0, idp_client_config_1.getIDPClientConfig)();
65
- return (0, client_key_1.resolveClientKey)(config, 'account/recovery/verify-code').key;
66
- }
67
- catch (e) {
68
- console.error('[account/recovery/verify-code] could not resolve IDP client config for client_id:', e);
69
- return null;
70
- }
71
- }
72
14
  async function POST(req) {
73
- let payload = {};
74
- try {
75
- payload = await req.json();
76
- }
77
- catch {
78
- return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
79
- }
80
- const clientKey = await recoveryClientKey();
81
- const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/recovery/verify-code`, {
82
- method: 'POST',
83
- headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
84
- body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
15
+ return (0, forward_1.forwardRecoveryCall)(req, {
16
+ upstreamPath: '/api/Account/recovery/verify-code',
17
+ where: 'account/recovery/verify-code',
18
+ failureCode: 'verify_code_failed',
19
+ fallbackMessage: 'Failed to verify the code',
85
20
  });
86
- if (!result.ok) {
87
- return server_1.NextResponse.json({
88
- success: false,
89
- error: 'verify_code_failed',
90
- message: result.json?.message || 'Upstream error',
91
- meta: { status: result.status, attemptedRefresh: result.attemptedRefresh },
92
- }, { status: result.status });
93
- }
94
- return server_1.NextResponse.json(result.json, { status: 200 });
95
21
  }
@@ -4,6 +4,7 @@ exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
5
  const idp_client_config_1 = require("../../lib/idp-client-config");
6
6
  const client_key_1 = require("../../lib/client-key");
7
+ const safe_error_response_1 = require("../../lib/safe-error-response");
7
8
  function getIdpUrl() {
8
9
  const url = process.env.IDP_URL;
9
10
  if (!url)
@@ -43,7 +44,23 @@ async function recoveryClientKey() {
43
44
  async function POST(req) {
44
45
  const IDP_URL = getIdpUrl();
45
46
  try {
46
- const payload = await req.json();
47
+ const raw = await req.json().catch(() => null);
48
+ const body = (raw && typeof raw === 'object' ? raw : {});
49
+ // PAY-1588: the IdP's ResetPasswordRequest binds exactly email/token/new_password/confirm_password. The client
50
+ // used to send password_reset_token / password / password_confirmation, which bind to nothing (400 at the IdP).
51
+ // Legacy names are accepted as aliases and mapped; only the four IdP fields are forwarded (whitelist).
52
+ const str = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
53
+ const fields = {
54
+ email: str(body.email),
55
+ token: str(body.token) ?? str(body.password_reset_token),
56
+ new_password: str(body.new_password) ?? str(body.password),
57
+ confirm_password: str(body.confirm_password) ?? str(body.password_confirmation),
58
+ };
59
+ const missing = Object.entries(fields).filter(([, v]) => v === undefined).map(([k]) => k);
60
+ if (missing.length) {
61
+ return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'Missing required fields: ' + missing.join(', ') }, { status: 400 });
62
+ }
63
+ const payload = fields;
47
64
  const clientKey = await recoveryClientKey();
48
65
  const upstream = await fetch(`${IDP_URL}/api/Account/reset-password`, {
49
66
  method: 'POST',
@@ -51,6 +68,12 @@ async function POST(req) {
51
68
  body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
52
69
  });
53
70
  const data = await upstream.json().catch(() => ({}));
71
+ // PAY-1438: anonymous route - on failure only the whitelisted envelope (utils/api reads `message`).
72
+ if (!upstream.ok) {
73
+ // PAY-1591: a password-policy refusal also carries the KNOWN rule codes so the page can say which rules failed.
74
+ const rules = (0, safe_error_response_1.projectPasswordPolicyRules)(data);
75
+ return server_1.NextResponse.json({ ...(0, safe_error_response_1.idpErrorEnvelope)(data, 'Failed to reset password'), ...(rules !== undefined ? { password_policy: { rules } } : {}), ...((0, safe_error_response_1.projectResetTokenInvalid)(data) ? { reset_token_invalid: true } : {}) }, { status: upstream.status });
76
+ }
54
77
  return server_1.NextResponse.json(data, { status: upstream.status });
55
78
  }
56
79
  catch {
@@ -7,18 +7,4 @@
7
7
  * @package @payez/next-mvp
8
8
  */
9
9
  import { NextRequest, NextResponse } from 'next/server';
10
- export declare function POST(req: NextRequest): Promise<NextResponse<{
11
- success: boolean;
12
- error: string;
13
- code: string;
14
- }> | NextResponse<{
15
- success: boolean;
16
- error: any;
17
- code: any;
18
- meta: {
19
- attemptedRefresh: boolean;
20
- };
21
- }> | NextResponse<{
22
- success: boolean;
23
- message: string;
24
- }>>;
10
+ export declare function POST(req: NextRequest): Promise<NextResponse<unknown>>;
@@ -12,6 +12,7 @@ exports.POST = POST;
12
12
  const server_1 = require("next/server");
13
13
  const idp_fetch_1 = require("../../lib/idp-fetch");
14
14
  const env_1 = require("../../config/env");
15
+ const two_factor_verify_failure_1 = require("../../lib/two-factor-verify-failure");
15
16
  async function POST(req) {
16
17
  try {
17
18
  // Parse request body
@@ -37,12 +38,8 @@ async function POST(req) {
37
38
  body: idpBody,
38
39
  });
39
40
  if (!result.ok) {
40
- return server_1.NextResponse.json({
41
- success: false,
42
- error: result.json?.message || `Failed to send ${method} code`,
43
- code: result.json?.code || 'IDP_ERROR',
44
- meta: { attemptedRefresh: result.attemptedRefresh },
45
- }, { status: result.status });
41
+ // PAY-1443: never relay the IDP's message/code - fixed sentence + stable code, status kept.
42
+ return (0, two_factor_verify_failure_1.twoFactorSendCodeFailure)(result, method);
46
43
  }
47
44
  return server_1.NextResponse.json({
48
45
  success: true,
@@ -32,7 +32,12 @@ async function POST(req) {
32
32
  body: JSON.stringify({ phone_number: phoneNumber }),
33
33
  });
34
34
  if (!result.ok) {
35
- console.error('[UPDATE_PHONE] IDP error:', result.status, result.json);
35
+ // PAY-1424: shape only, never the IDP body (profile endpoint - PII).
36
+ console.error('[UPDATE_PHONE] IDP error:', {
37
+ status: result.status,
38
+ bodyKeys: result.json && typeof result.json === 'object' ? Object.keys(result.json) : undefined,
39
+ errorKeys: result.json?.error && typeof result.json?.error === 'object' ? Object.keys(result.json?.error) : undefined,
40
+ });
36
41
  return server_1.NextResponse.json({
37
42
  success: false,
38
43
  error: {
@@ -48,9 +48,11 @@ async function POST(req) {
48
48
  });
49
49
  const responseData = await idpResponse.json().catch(() => ({}));
50
50
  if (!idpResponse.ok) {
51
+ // PAY-1424: shape only, never the envelope contents (this endpoint carries a password).
51
52
  console.error('[VALIDATE_PASSWORD] IDP error:', {
52
53
  status: idpResponse.status,
53
- response: responseData,
54
+ bodyKeys: responseData && typeof responseData === 'object' ? Object.keys(responseData) : undefined,
55
+ errorKeys: responseData?.error && typeof responseData?.error === 'object' ? Object.keys(responseData?.error) : undefined,
54
56
  });
55
57
  return server_1.NextResponse.json({
56
58
  is_valid: false,
@@ -7,20 +7,4 @@
7
7
  * @package @payez/next-mvp
8
8
  */
9
9
  import { NextRequest, NextResponse } from 'next/server';
10
- export declare function POST(req: NextRequest): Promise<NextResponse<{
11
- success: boolean;
12
- error: string;
13
- code: string;
14
- }> | NextResponse<{
15
- success: boolean;
16
- error: any;
17
- code: any;
18
- meta: {
19
- attemptedRefresh: boolean;
20
- };
21
- }> | NextResponse<{
22
- success: boolean;
23
- verificationSuccessful: boolean;
24
- twoFactorSessionVerified: boolean;
25
- message: any;
26
- }>>;
10
+ export declare function POST(req: NextRequest): Promise<NextResponse<unknown>>;