@payez/next-mvp 4.13.0 → 4.14.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 (74) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/api-handlers/account/profile.d.ts +2 -39
  3. package/dist/api-handlers/account/profile.js +5 -2
  4. package/dist/api-handlers/anon/preferences.d.ts +1 -14
  5. package/dist/api-handlers/anon/preferences.js +5 -5
  6. package/dist/api-handlers/auth/credential-login.js +46 -0
  7. package/dist/api-handlers/health.d.ts +5 -0
  8. package/dist/api-handlers/health.js +48 -0
  9. package/dist/api-handlers/mfa/first-enrol.d.ts +5 -0
  10. package/dist/api-handlers/mfa/first-enrol.js +58 -0
  11. package/dist/api-handlers/session/refresh-viability.d.ts +3 -15
  12. package/dist/api-handlers/session/refresh-viability.js +5 -2
  13. package/dist/api-handlers/session/viability.d.ts +3 -8
  14. package/dist/api-handlers/session/viability.js +5 -2
  15. package/dist/auth/better-auth-exposure.d.ts +11 -0
  16. package/dist/auth/better-auth-exposure.js +73 -0
  17. package/dist/auth/better-auth.js +13 -1
  18. package/dist/auth/route-config.js +9 -0
  19. package/dist/components/auth/CredentialSignInForm.d.ts +6 -1
  20. package/dist/components/auth/CredentialSignInForm.js +15 -1
  21. package/dist/components/mfa/FirstEnrolPanel.d.ts +21 -0
  22. package/dist/components/mfa/FirstEnrolPanel.js +115 -0
  23. package/dist/lib/credential-sign-in-client.d.ts +4 -0
  24. package/dist/lib/credential-sign-in-client.js +13 -1
  25. package/dist/lib/first-enrol.d.ts +66 -0
  26. package/dist/lib/first-enrol.js +188 -0
  27. package/dist/lib/no-store.d.ts +13 -0
  28. package/dist/lib/no-store.js +35 -0
  29. package/dist/pages/first-enroll/page.d.ts +7 -0
  30. package/dist/pages/first-enroll/page.js +15 -0
  31. package/dist/routes/auth/session.d.ts +2 -9
  32. package/dist/routes/auth/session.js +5 -2
  33. package/dist/routes/auth/viability.d.ts +3 -54
  34. package/dist/routes/auth/viability.js +5 -2
  35. package/dist/routes/health.d.ts +1 -0
  36. package/dist/routes/health.js +16 -0
  37. package/dist/routes/mfa/first-enrol-confirm.d.ts +10 -0
  38. package/dist/routes/mfa/first-enrol-confirm.js +14 -0
  39. package/dist/routes/mfa/first-enrol-enroll.d.ts +10 -0
  40. package/dist/routes/mfa/first-enrol-enroll.js +14 -0
  41. package/dist/routes/mfa/methods.d.ts +2 -2
  42. package/dist/routes/mfa/methods.js +3 -1
  43. package/dist/routes/passkeys/list.d.ts +2 -10
  44. package/dist/routes/passkeys/list.js +4 -2
  45. package/dist/routes/session/refresh-viability.d.ts +2 -1
  46. package/dist/routes/session/refresh-viability.js +4 -2
  47. package/dist/utils/api.d.ts +33 -6
  48. package/dist/utils/api.js +35 -2
  49. package/package.json +26 -1
  50. package/src/api-handlers/account/profile.ts +5 -1
  51. package/src/api-handlers/anon/preferences.ts +5 -4
  52. package/src/api-handlers/auth/credential-login.ts +49 -0
  53. package/src/api-handlers/health.ts +53 -0
  54. package/src/api-handlers/mfa/first-enrol.ts +55 -0
  55. package/src/api-handlers/session/refresh-viability.ts +5 -1
  56. package/src/api-handlers/session/viability.ts +6 -2
  57. package/src/auth/better-auth-exposure.ts +69 -0
  58. package/src/auth/better-auth.ts +15 -1
  59. package/src/auth/route-config.ts +9 -0
  60. package/src/components/auth/CredentialSignInForm.tsx +18 -1
  61. package/src/components/mfa/FirstEnrolPanel.tsx +189 -0
  62. package/src/lib/credential-sign-in-client.ts +13 -1
  63. package/src/lib/first-enrol.ts +180 -0
  64. package/src/lib/no-store.ts +30 -0
  65. package/src/pages/first-enroll/page.tsx +19 -0
  66. package/src/routes/auth/session.ts +5 -1
  67. package/src/routes/auth/viability.ts +6 -2
  68. package/src/routes/health.ts +14 -0
  69. package/src/routes/mfa/first-enrol-confirm.ts +10 -0
  70. package/src/routes/mfa/first-enrol-enroll.ts +10 -0
  71. package/src/routes/mfa/methods.ts +3 -1
  72. package/src/routes/passkeys/list.ts +5 -1
  73. package/src/routes/session/refresh-viability.ts +5 -1
  74. package/src/utils/api.ts +50 -8
@@ -15,7 +15,7 @@
15
15
  * @since auth-ready-v2
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.GET = GET;
18
+ exports.GET = void 0;
19
19
  const revocation_fields_1 = require("../../auth/revocation-fields");
20
20
  const server_1 = require("next/server");
21
21
  const auth_1 = require("../../server/auth");
@@ -23,6 +23,7 @@ const session_store_1 = require("../../lib/session-store");
23
23
  const idp_client_config_1 = require("../../lib/idp-client-config");
24
24
  const safe_error_response_1 = require("../../lib/safe-error-response");
25
25
  const token_log_id_1 = require("../../lib/token-log-id");
26
+ const no_store_1 = require("../../lib/no-store");
26
27
  const sessions_1 = require("../../vibe/sessions");
27
28
  /**
28
29
  * Get tenant-wide 2FA requirement from cached client config (from broker handshake)
@@ -135,7 +136,7 @@ function resolveClientId(sessionClientId) {
135
136
  * - needsRefresh: boolean - Whether a refresh is recommended
136
137
  * - expiresIn: number - Seconds until token expires
137
138
  */
138
- async function GET(req) {
139
+ async function getHandler(req) {
139
140
  try {
140
141
  // PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
141
142
  // session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
@@ -495,3 +496,5 @@ async function GET(req) {
495
496
  }, { status: 500 });
496
497
  }
497
498
  }
499
+ /** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
500
+ exports.GET = (0, no_store_1.noStore)(getHandler);
@@ -0,0 +1 @@
1
+ export declare const GET: () => Promise<Response>;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GET = void 0;
4
+ /**
5
+ * Ready-to-use /api/health route: reports Redis reachability (PAY-2804).
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // app/api/health/route.ts
10
+ * export const runtime = 'nodejs';
11
+ * export { GET } from '@payez/next-mvp/routes/health';
12
+ * ```
13
+ */
14
+ const health_1 = require("../api-handlers/health");
15
+ const redis_1 = require("../lib/redis");
16
+ exports.GET = (0, health_1.createHealthHandler)(() => (0, redis_1.getRedis)());
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AUTH-045 first-enrolment: confirm the first authenticator code, clear the enrolment cookie, return the recovery codes. Mints no session.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/auth/first-enrol/confirm/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-confirm';
8
+ * ```
9
+ */
10
+ export { POST_CONFIRM as POST } from '../../api-handlers/mfa/first-enrol';
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = void 0;
4
+ /**
5
+ * AUTH-045 first-enrolment: confirm the first authenticator code, clear the enrolment cookie, return the recovery codes. Mints no session.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // app/api/auth/first-enrol/confirm/route.ts
10
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-confirm';
11
+ * ```
12
+ */
13
+ var first_enrol_1 = require("../../api-handlers/mfa/first-enrol");
14
+ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return first_enrol_1.POST_CONFIRM; } });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AUTH-045 first-enrolment: begin TOTP enrolment for a user the IdP told to enrol (uses ONLY the httpOnly enrolment cookie the credential door parked).
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/auth/first-enrol/enroll/route.ts
7
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-enroll';
8
+ * ```
9
+ */
10
+ export { POST_ENROLL as POST } from '../../api-handlers/mfa/first-enrol';
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = void 0;
4
+ /**
5
+ * AUTH-045 first-enrolment: begin TOTP enrolment for a user the IdP told to enrol (uses ONLY the httpOnly enrolment cookie the credential door parked).
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // app/api/auth/first-enrol/enroll/route.ts
10
+ * export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-enroll';
11
+ * ```
12
+ */
13
+ var first_enrol_1 = require("../../api-handlers/mfa/first-enrol");
14
+ Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return first_enrol_1.POST_ENROLL; } });
@@ -10,8 +10,8 @@
10
10
  * ```
11
11
  */
12
12
  import { NextRequest } from 'next/server';
13
- import { GET as listMethods } from '../../api-handlers/mfa/methods';
14
- export declare const GET: typeof listMethods;
13
+ /** PAY-2994: the user's MFA methods; never cached (noStore). */
14
+ export declare const GET: (req: NextRequest) => Promise<Response>;
15
15
  type Ctx = {
16
16
  params: Promise<{
17
17
  id: string;
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GET = void 0;
4
4
  exports.DELETE = DELETE;
5
5
  const methods_1 = require("../../api-handlers/mfa/methods");
6
- exports.GET = methods_1.GET;
6
+ const no_store_1 = require("../../lib/no-store");
7
+ /** PAY-2994: the user's MFA methods; never cached (noStore). */
8
+ exports.GET = (0, no_store_1.noStore)(methods_1.GET);
7
9
  async function DELETE(req, ctx) {
8
10
  const { id } = await ctx.params;
9
11
  return (0, methods_1.DELETE)(req, id);
@@ -1,10 +1,2 @@
1
- /**
2
- * Ready-to-Use Passkey List Route
3
- *
4
- * @example
5
- * ```typescript
6
- * // app/api/passkeys/route.ts
7
- * export { GET } from '@payez/next-mvp/routes/passkeys/list';
8
- * ```
9
- */
10
- export { GET } from '../../api-handlers/passkeys/list';
1
+ /** PAY-2994: the signed-in user's passkeys; never cached (noStore). */
2
+ export declare const GET: (req: import("next/server").NextRequest) => Promise<Response>;
@@ -10,5 +10,7 @@ exports.GET = void 0;
10
10
  * export { GET } from '@payez/next-mvp/routes/passkeys/list';
11
11
  * ```
12
12
  */
13
- var list_1 = require("../../api-handlers/passkeys/list");
14
- Object.defineProperty(exports, "GET", { enumerable: true, get: function () { return list_1.GET; } });
13
+ const list_1 = require("../../api-handlers/passkeys/list");
14
+ const no_store_1 = require("../../lib/no-store");
15
+ /** PAY-2994: the signed-in user's passkeys; never cached (noStore). */
16
+ exports.GET = (0, no_store_1.noStore)(list_1.GET);
@@ -13,4 +13,5 @@
13
13
  * @version 2.0.0
14
14
  * @since auth-ready-v2
15
15
  */
16
- export { GET } from '../../api-handlers/session/refresh-viability';
16
+ /** PAY-2994: refresh-token viability of this session; never cached (noStore). */
17
+ export declare const GET: (req: import("next/server").NextRequest) => Promise<Response>;
@@ -16,5 +16,7 @@
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.GET = void 0;
19
- var refresh_viability_1 = require("../../api-handlers/session/refresh-viability");
20
- Object.defineProperty(exports, "GET", { enumerable: true, get: function () { return refresh_viability_1.GET; } });
19
+ const refresh_viability_1 = require("../../api-handlers/session/refresh-viability");
20
+ const no_store_1 = require("../../lib/no-store");
21
+ /** PAY-2994: refresh-token viability of this session; never cached (noStore). */
22
+ exports.GET = (0, no_store_1.noStore)(refresh_viability_1.GET);
@@ -49,6 +49,38 @@ export declare const RESET_BREACHED_TEXT = "This password appears in a known dat
49
49
  * they can retry.
50
50
  */
51
51
  export declare function resetFailureText(status: number, rules?: string[], policy?: boolean, tokenInvalid?: boolean): string;
52
+ /**
53
+ * SMS-first sign-up (door 3b): when the phone code is right but the email is still unconfirmed, the server answers 200 success with
54
+ * `next_step = EMAIL_CONFIRMATION_REQUIRED` and NO reset token. `verifyOnboardingSmsCode` surfaces that as `nextStep`;
55
+ * `interpretSmsVerification` turns a result into ONE outcome, testing `nextStep` BEFORE `success`, so a consumer that uses it can
56
+ * never reach "verified" without a reset token or with an unfinished step. The sentences are the package's own, never the server's text.
57
+ */
58
+ export declare const SMS_NEXT_STEP_EMAIL_CONFIRMATION_REQUIRED = "EMAIL_CONFIRMATION_REQUIRED";
59
+ export declare const SMS_EMAIL_CONFIRMATION_TEXT = "Your phone is verified. Confirm your email to finish signing up.";
60
+ export declare const SMS_MORE_STEPS_TEXT = "One more step is needed to finish signing up.";
61
+ export interface SmsCodeVerification {
62
+ success: boolean;
63
+ resetToken: string;
64
+ email: string;
65
+ clientKey: string;
66
+ /** Present when the server says more steps remain (an UNRECOGNISED value is kept as a non-empty string, never dropped). */
67
+ nextStep?: string;
68
+ }
69
+ export type SmsVerificationOutcome = {
70
+ kind: 'verified';
71
+ resetToken: string;
72
+ email: string;
73
+ clientKey: string;
74
+ } | {
75
+ kind: 'email_confirmation_required';
76
+ message: string;
77
+ } | {
78
+ kind: 'more_steps';
79
+ message: string;
80
+ } | {
81
+ kind: 'not_verified';
82
+ };
83
+ export declare function interpretSmsVerification(result: SmsCodeVerification): SmsVerificationOutcome;
52
84
  export declare class AccountApi {
53
85
  /**
54
86
  * Get masked user info (email, phone, authenticator status)
@@ -90,12 +122,7 @@ export declare class AccountApi {
90
122
  email: string;
91
123
  clientKey: string;
92
124
  }>;
93
- verifyOnboardingSmsCode(onboardToken: string, code: string, clientKey?: string): Promise<{
94
- success: boolean;
95
- resetToken: string;
96
- email: string;
97
- clientKey: string;
98
- }>;
125
+ verifyOnboardingSmsCode(onboardToken: string, code: string, clientKey?: string): Promise<SmsCodeVerification>;
99
126
  setOnboardingPassword(data: {
100
127
  onboardToken: string;
101
128
  email: string;
package/dist/utils/api.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.accountApi = exports.AccountApi = exports.RESET_BREACHED_TEXT = exports.RESET_NEUTRAL_TEXT = exports.RECOVERY_EXPIRED_TEXT = exports.ApiError = void 0;
3
+ exports.accountApi = exports.AccountApi = exports.SMS_MORE_STEPS_TEXT = exports.SMS_EMAIL_CONFIRMATION_TEXT = exports.SMS_NEXT_STEP_EMAIL_CONFIRMATION_REQUIRED = exports.RESET_BREACHED_TEXT = exports.RESET_NEUTRAL_TEXT = exports.RECOVERY_EXPIRED_TEXT = exports.ApiError = void 0;
4
4
  exports.apiFetch = apiFetch;
5
5
  exports.resetFailureText = resetFailureText;
6
+ exports.interpretSmsVerification = interpretSmsVerification;
6
7
  const standardized_client_api_1 = require("../lib/standardized-client-api");
7
8
  const password_rules_1 = require("../lib/password-rules");
8
9
  class ApiError extends Error {
@@ -110,6 +111,30 @@ function resetFailureText(status, rules = [], policy = false, tokenInvalid = fal
110
111
  }
111
112
  return "We couldn't reset your password just now. Please try again.";
112
113
  }
114
+ /**
115
+ * SMS-first sign-up (door 3b): when the phone code is right but the email is still unconfirmed, the server answers 200 success with
116
+ * `next_step = EMAIL_CONFIRMATION_REQUIRED` and NO reset token. `verifyOnboardingSmsCode` surfaces that as `nextStep`;
117
+ * `interpretSmsVerification` turns a result into ONE outcome, testing `nextStep` BEFORE `success`, so a consumer that uses it can
118
+ * never reach "verified" without a reset token or with an unfinished step. The sentences are the package's own, never the server's text.
119
+ */
120
+ exports.SMS_NEXT_STEP_EMAIL_CONFIRMATION_REQUIRED = 'EMAIL_CONFIRMATION_REQUIRED';
121
+ exports.SMS_EMAIL_CONFIRMATION_TEXT = 'Your phone is verified. Confirm your email to finish signing up.';
122
+ exports.SMS_MORE_STEPS_TEXT = 'One more step is needed to finish signing up.';
123
+ function interpretSmsVerification(result) {
124
+ // success:false WINS over any step (BAPert 88508): a failed answer must never say "Your phone is verified".
125
+ if (!result.success)
126
+ return { kind: 'not_verified' };
127
+ // For every answer that is not a failure, nextStep comes BEFORE the token test: a step that is not finished is never "verified".
128
+ if (result.nextStep) {
129
+ return result.nextStep === exports.SMS_NEXT_STEP_EMAIL_CONFIRMATION_REQUIRED
130
+ ? { kind: 'email_confirmation_required', message: exports.SMS_EMAIL_CONFIRMATION_TEXT }
131
+ : { kind: 'more_steps', message: exports.SMS_MORE_STEPS_TEXT };
132
+ }
133
+ // success with an EMPTY reset token is the shape that is dangerous (set-password would be called with ''): not verified.
134
+ if (!result.resetToken)
135
+ return { kind: 'more_steps', message: exports.SMS_MORE_STEPS_TEXT };
136
+ return { kind: 'verified', resetToken: result.resetToken, email: result.email, clientKey: result.clientKey };
137
+ }
113
138
  class AccountApi {
114
139
  /**
115
140
  * Get masked user info (email, phone, authenticator status)
@@ -303,11 +328,19 @@ class AccountApi {
303
328
  }
304
329
  // Extract from nested data structure
305
330
  const data = result.data?.data || result.data || {};
331
+ // next_step (door 3b): read from the nested shapes AND a flat body. Absent / null / '' = no step; a non-string value is an
332
+ // UNRECOGNISED step and is kept as a non-empty string (never dropped, never mistaken for the known one). That includes false and 0:
333
+ // a server that means "no step" must send nothing or null (or '').
334
+ const rawStep = data.next_step ?? result.next_step;
335
+ const nextStep = rawStep === undefined || rawStep === null || rawStep === ''
336
+ ? undefined
337
+ : typeof rawStep === 'string' ? rawStep : 'UNRECOGNISED_NEXT_STEP';
306
338
  return {
307
339
  success: data.success !== false,
308
340
  resetToken: data.reset_token || '',
309
341
  email: data.email || '',
310
- clientKey: data.client_key || ''
342
+ clientKey: data.client_key || '',
343
+ ...(nextStep !== undefined ? { nextStep } : {})
311
344
  };
312
345
  }
313
346
  async setOnboardingPassword(data, clientKey) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.13.0",
3
+ "version": "4.14.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -505,6 +505,11 @@
505
505
  "require": "./dist/api-handlers/auth/validate.js",
506
506
  "default": "./api-handlers/auth/validate.js"
507
507
  },
508
+ "./api-handlers/health": {
509
+ "types": "./dist/api-handlers/health.d.ts",
510
+ "require": "./dist/api-handlers/health.js",
511
+ "default": "./dist/api-handlers/health.js"
512
+ },
508
513
  "./api-handlers/session/refresh-viability": {
509
514
  "types": "./dist/api-handlers/session/refresh-viability.d.ts",
510
515
  "require": "./dist/api-handlers/session/refresh-viability.js",
@@ -705,6 +710,11 @@
705
710
  "require": "./dist/routes/auth/viability.js",
706
711
  "default": "./dist/routes/auth/viability.js"
707
712
  },
713
+ "./routes/health": {
714
+ "types": "./dist/routes/health.d.ts",
715
+ "require": "./dist/routes/health.js",
716
+ "default": "./dist/routes/health.js"
717
+ },
708
718
  "./routes/session/refresh-viability": {
709
719
  "types": "./dist/routes/session/refresh-viability.d.ts",
710
720
  "require": "./dist/routes/session/refresh-viability.js",
@@ -980,6 +990,16 @@
980
990
  "require": "./dist/routes/mfa/totp-confirm.js",
981
991
  "default": "./dist/routes/mfa/totp-confirm.js"
982
992
  },
993
+ "./routes/mfa/first-enrol-enroll": {
994
+ "types": "./dist/routes/mfa/first-enrol-enroll.d.ts",
995
+ "require": "./dist/routes/mfa/first-enrol-enroll.js",
996
+ "default": "./dist/routes/mfa/first-enrol-enroll.js"
997
+ },
998
+ "./routes/mfa/first-enrol-confirm": {
999
+ "types": "./dist/routes/mfa/first-enrol-confirm.d.ts",
1000
+ "require": "./dist/routes/mfa/first-enrol-confirm.js",
1001
+ "default": "./dist/routes/mfa/first-enrol-confirm.js"
1002
+ },
983
1003
  "./routes/mfa/totp-verify": {
984
1004
  "types": "./dist/routes/mfa/totp-verify.d.ts",
985
1005
  "require": "./dist/routes/mfa/totp-verify.js",
@@ -1090,6 +1110,11 @@
1090
1110
  "require": "./dist/components/mfa/TotpEnrollPanel.js",
1091
1111
  "default": "./dist/components/mfa/TotpEnrollPanel.js"
1092
1112
  },
1113
+ "./dist/pages/first-enroll": {
1114
+ "types": "./dist/pages/first-enroll/page.d.ts",
1115
+ "require": "./dist/pages/first-enroll/page.js",
1116
+ "default": "./dist/pages/first-enroll/page.js"
1117
+ },
1093
1118
  "./dist/pages/enroll": {
1094
1119
  "types": "./dist/pages/enroll/page.d.ts",
1095
1120
  "require": "./dist/pages/enroll/page.js",
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../config/env';
4
+ import { noStore } from '../../lib/no-store';
4
5
 
5
6
  /**
6
7
  * Account Profile API Handler
@@ -41,7 +42,7 @@ import { ENV_CONFIG } from '../../config/env';
41
42
  * @package @payez/next-mvp
42
43
  */
43
44
 
44
- export async function GET(req: NextRequest) {
45
+ async function getProfile(req: NextRequest) {
45
46
  const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Account/profile`, {
46
47
  method: 'GET',
47
48
  headers: { 'Content-Type': 'application/json' },
@@ -65,6 +66,9 @@ export async function GET(req: NextRequest) {
65
66
  return NextResponse.json(result.json, { status: 200 });
66
67
  }
67
68
 
69
+ /** PAY-2994: the profile is one person's data; never cached (noStore). */
70
+ export const GET = noStore(getProfile);
71
+
68
72
  export async function PUT(req: NextRequest) {
69
73
  let body = '{}';
70
74
  try {
@@ -16,13 +16,12 @@ import {
16
16
  AnonSessionPreferences,
17
17
  } from '../../lib/anon-session';
18
18
  import { errForLog } from '../../lib/log-safe';
19
+ import { noStore } from '../../lib/no-store';
19
20
 
20
21
  const COOKIE_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
21
22
 
22
- /**
23
- * GET handler - retrieves anonymous session preferences
24
- */
25
- export async function GET(request: NextRequest) {
23
+ /** Retrieves anonymous session preferences. Exported as GET below, wrapped in noStore (PAY-2994). */
24
+ async function getPreferences(request: NextRequest) {
26
25
  try {
27
26
  const cookieStore = await cookies();
28
27
  let anonId = cookieStore.get(ANON_COOKIE_NAME)?.value;
@@ -64,6 +63,8 @@ export async function GET(request: NextRequest) {
64
63
  /**
65
64
  * POST handler - updates anonymous session preferences
66
65
  */
66
+ export const GET = noStore(getPreferences);
67
+
67
68
  export async function POST(request: NextRequest) {
68
69
  try {
69
70
  const cookieStore = await cookies();
@@ -49,6 +49,8 @@ import { DEVICE_ID_HEADER, isValidDeviceId } from '../../lib/device-id';
49
49
  import { classifyTwofaRejection, type TwofaLaneResult } from '../../lib/twofa-rejection';
50
50
  import { bearerFetch } from '../../lib/bearer-fetch';
51
51
  import { errForLog } from '../../lib/log-safe';
52
+ // AUTH-045: the httpOnly cookie that carries the enrolment-scoped token from the code turn to the two enrolment routes (ONE name, defined in lib/first-enrol).
53
+ import { ENROL_COOKIE, ENROL_COOKIE_MAX_AGE } from '../../lib/first-enrol';
52
54
 
53
55
  // `export const runtime = 'nodejs'` must be written LITERALLY in the app's route file (Next ignores a re-exported segment config).
54
56
 
@@ -348,6 +350,53 @@ export async function POST(req: NextRequest) {
348
350
  const vresult = verifyData?.data?.result ?? verifyData?.data ?? verifyData;
349
351
  const vAccess: string | undefined = vresult?.access_token;
350
352
  const vRefresh: string | undefined = vresult?.refresh_token;
353
+ // PAY-3083 (AUTH-045): ACCESS WITHOUT REFRESH AT THE CODE TURN IS NOT A FAILURE - IT IS ENROLMENT.
354
+ //
355
+ // The IdP withholds the refresh token for a user who has no usable second factor on THIS client.
356
+ // TOTP enrolment is PER CLIENT (DotNetPert 99446 sec A, client 90717 / user 1175): the password turn
357
+ // answered Require2FA=true, IssueRefreshToken=false, step=enroll_mfa, and this verify turn then
358
+ // accepted the emailed code as CONTACT PROOF and issued an ENROLMENT-SCOPED access token, again with
359
+ // no refresh token. That is exactly what PAY-2798 requires of the IdP - the IdP is RIGHT.
360
+ //
361
+ // This branch used to read "access, no refresh" as an incomplete response and answer 502, so the
362
+ // developer who had just proved their contact was told the server was broken and could never reach
363
+ // the enrolment page: the 502 the field saw as
364
+ // '[ADMIN-LOGIN] verify response missing token { hasAccess: true, hasRefresh: false }'.
365
+ //
366
+ // Send them to ENROLMENT instead, in the ONE contract the door and the form already share for it
367
+ // (PAY-2630): requiresTwoFactor + nextStep 'enroll_mfa'. No session is minted - an enrolment-scoped
368
+ // token is not a signed-in session - and the token value is NEVER logged nor echoed (BAPert 99521 #2).
369
+ //
370
+ // Guard kept: no access token AT ALL (a refresh-only or empty body) is still a broken success and
371
+ // still 502 below - this widening is only for a genuine access-with-no-refresh answer.
372
+ if (vAccess && !vRefresh) {
373
+ const step = vresult?.next_step === 'enroll_mfa' || vresult?.next_step === 'mfa_verify' ? vresult.next_step : 'enroll_mfa';
374
+ const enrolling = step === 'enroll_mfa';
375
+ const answer = NextResponse.json(
376
+ {
377
+ success: false,
378
+ requiresTwoFactor: true,
379
+ acceptedFactors: acceptedFactorsFor('email'),
380
+ sentVia: null,
381
+ codeRequested: false,
382
+ emailSent: false,
383
+ nextStep: step,
384
+ // AUTH-045 (walk 14 Nov 2026): requiresTwoFactor alone is also the password turn's "a code was sent", so the form re-drew the code box
385
+ // and looped. This flag is the ONLY signal that means "go enrol"; it is set on this answer and nowhere else.
386
+ ...(enrolling ? { enrolmentRequired: true } : {}),
387
+ },
388
+ { status: 200 },
389
+ );
390
+ if (enrolling) {
391
+ // No session exists for an enrolment-scoped token, and the enrolment page's actions need one, so the token rides in this httpOnly
392
+ // cookie to the enrolment routes (the portal's PAY-2798 part 2 pattern): never in a body, a URL or a log; strict; 600 s.
393
+ answer.cookies.set(ENROL_COOKIE, vAccess, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge: ENROL_COOKIE_MAX_AGE });
394
+ // The provisional bearer is spent: the enrolment token replaces it.
395
+ answer.cookies.set('adm2fa', '', { path: '/', maxAge: 0 });
396
+ answer.cookies.set('adm2fa_lane', '', { path: '/', maxAge: 0 });
397
+ }
398
+ return answer;
399
+ }
351
400
  if (!vAccess || !vRefresh) {
352
401
  console.error('[ADMIN-LOGIN] verify response missing tokens', { hasAccess: !!vAccess, hasRefresh: !!vRefresh });
353
402
  return NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * /api/health handler (PAY-2804, AUTH-045; BAPert ruling 88065).
3
+ *
4
+ * Sessions are Redis-only. The body reports state ONLY: `redis: 'up' | 'down' | 'unknown'` and
5
+ * `status: 'ok' | 'degraded'`. No REDIS_URL, host, port, error text or stack, to any caller (anonymous route).
6
+ *
7
+ * The HTTP status is 200 even when Redis is down: this route is the ALB target-group check, and a 503
8
+ * there would make the ALB pull every task for an outage that replacing tasks cannot fix.
9
+ * HEALTH_REDIS_FAILS_STATUS=1 turns Redis-down into 503 as a READINESS setting for a deployer's OWN probe;
10
+ * it is default off and must never be set on a load-balancer health check.
11
+ */
12
+ import { NextResponse } from 'next/server';
13
+
14
+ export interface HealthRedis {
15
+ ping(): Promise<unknown>;
16
+ }
17
+
18
+ export type RedisState = 'up' | 'down' | 'unknown';
19
+
20
+ const PING_BUDGET_MS = 2000;
21
+
22
+ async function probe(redis: HealthRedis): Promise<RedisState> {
23
+ let timer: ReturnType<typeof setTimeout> | undefined;
24
+ try {
25
+ const timeout = new Promise<never>((_, reject) => {
26
+ timer = setTimeout(() => reject(new Error('ping timeout')), PING_BUDGET_MS);
27
+ });
28
+ await Promise.race([redis.ping(), timeout]);
29
+ return 'up';
30
+ } catch {
31
+ return 'down';
32
+ } finally {
33
+ if (timer) clearTimeout(timer);
34
+ }
35
+ }
36
+
37
+ export function createHealthHandler(getRedis: () => HealthRedis) {
38
+ return async function GET(): Promise<Response> {
39
+ let redis: RedisState;
40
+ let client: HealthRedis | undefined;
41
+ try {
42
+ client = getRedis();
43
+ } catch {
44
+ client = undefined;
45
+ }
46
+ redis = client ? await probe(client) : 'unknown';
47
+ const failsStatus = process.env.HEALTH_REDIS_FAILS_STATUS === '1';
48
+ return NextResponse.json(
49
+ { status: redis === 'up' ? 'ok' : 'degraded', redis },
50
+ { status: redis !== 'up' && failsStatus ? 503 : 200, headers: { 'Cache-Control': 'no-store' } },
51
+ );
52
+ };
53
+ }
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
4
+ import { resolveClientKey } from '../../lib/client-key';
5
+ import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
6
+ import { ENROL_COOKIE, LIMIT_PER_COOKIE, attemptAllowed, runEnrolStep, type EnrolStep } from '../../lib/first-enrol';
7
+
8
+ /**
9
+ * AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969 rows 3-5): the TWO routes a user the IdP told to enrol uses, built on lib/first-enrol.
10
+ *
11
+ * WHO MAY CALL: the browser that holds the httpOnly enrolment cookie the credential door parked (ENROL_COOKIE). This is the ONLY module besides the
12
+ * door that reads that cookie, and it serves ONLY these two operations; every other package route stays session-gated and answers 401 to a caller
13
+ * holding nothing but this cookie (a gate row holds that). No cookie -> 401 SIGN_IN_AGAIN and no IdP call.
14
+ *
15
+ * What follows a confirm (BAPert 100969 row 5): the cookie is cleared and the answer carries the recovery codes; NO session is minted here and no new
16
+ * way to mint one is added. The person signs in again with their password and the authenticator, through the same credential door as before.
17
+ *
18
+ * The token never leaves the server: not in a body, a URL, a log or a Referer. The IdP's prose is never relayed.
19
+ */
20
+ const digest = (s: string) => createHash('sha256').update(s).digest('hex');
21
+
22
+ const cookieOpts = (maxAge: number) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' as const, path: '/', maxAge });
23
+
24
+ function reply(status: number, body: Record<string, unknown>, clear: boolean): NextResponse {
25
+ const res = NextResponse.json(body, { status, headers: NO_STORE });
26
+ if (clear) res.cookies.set(ENROL_COOKIE, '', cookieOpts(0));
27
+ return res;
28
+ }
29
+
30
+ export function enrolStepPOST(step: EnrolStep) {
31
+ return async function POST(req: NextRequest): Promise<NextResponse> {
32
+ const refused = refuseNonJson(req);
33
+ if (refused) return refused;
34
+ const token = req.cookies.get(ENROL_COOKIE)?.value;
35
+ if (!token) return reply(401, { success: false, code: 'SIGN_IN_AGAIN' }, false);
36
+ if (!attemptAllowed(`${step}|ck|${digest(token)}`, LIMIT_PER_COOKIE)) return reply(429, { success: false, code: 'TOO_MANY', error: 'Too many attempts. Wait a few minutes and try again.' }, true);
37
+
38
+ const idp = process.env.IDP_URL;
39
+ let clientKey = '';
40
+ try {
41
+ const config = await getIDPClientConfig();
42
+ clientKey = resolveClientKey(config, 'first-enrol').key ?? '';
43
+ } catch {
44
+ return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
45
+ }
46
+ if (!idp || !clientKey) return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
47
+
48
+ const body = await req.json().catch(() => null);
49
+ const r = await runEnrolStep({ fetchImpl: (u, i) => fetch(u, i), idp, clientKey }, { step, token, body });
50
+ return reply(r.status, r.body, r.cookie === 'clear');
51
+ };
52
+ }
53
+
54
+ export const POST_ENROLL = enrolStepPOST('enroll');
55
+ export const POST_CONFIRM = enrolStepPOST('confirm');
@@ -5,9 +5,10 @@ import { getTokenTestAware } from '../../lib/test-aware-get-token';
5
5
  import { logger } from '../../config/logger';
6
6
  import { tokenLogId } from '../../lib/token-log-id';
7
7
  import { errForLog } from '../../lib/log-safe';
8
+ import { noStore } from '../../lib/no-store';
8
9
  import { resolveSessionState } from '../../server/auth';
9
10
 
10
- export async function GET(req: NextRequest) {
11
+ async function getRefreshViability(req: NextRequest) {
11
12
  try {
12
13
  const sessionToken = req.nextUrl.searchParams.get('token');
13
14
  let finalSessionToken = sessionToken;
@@ -55,3 +56,6 @@ export async function GET(req: NextRequest) {
55
56
  return NextResponse.json({ canRefresh: false, reason: 'check_error', error: 'Failed to check refresh viability', code: 'INTERNAL_ERROR', correlationId }, { status: 500 });
56
57
  }
57
58
  }
59
+
60
+ /** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
61
+ export const GET = noStore(getRefreshViability);
@@ -13,8 +13,9 @@ import { getIDPClientConfig } from '../../lib/idp-client-config';
13
13
  import { tokenLogId } from '../../lib/token-log-id';
14
14
  import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
15
15
  import { errForLog } from '../../lib/log-safe';
16
+ import { noStore } from '../../lib/no-store';
16
17
 
17
- export async function GET(req: NextRequest) {
18
+ async function getViability(req: NextRequest) {
18
19
  try {
19
20
  // Ensure initialization is complete (auth signing secret resolved from IDP)
20
21
  if (!process.env.BETTER_AUTH_SECRET && !process.env.NEXTAUTH_SECRET) {
@@ -241,4 +242,7 @@ export async function GET(req: NextRequest) {
241
242
  console.error('[API Viability] Error checking session viability:', errForLog(error));
242
243
  return NextResponse.json({ authenticated: false }, { status: 500 });
243
244
  }
244
- }
245
+ }
246
+
247
+ /** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
248
+ export const GET = noStore(getViability);