@payez/next-mvp 4.13.0 → 4.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +12 -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/health.d.ts +5 -0
  7. package/dist/api-handlers/health.js +48 -0
  8. package/dist/api-handlers/session/refresh-viability.d.ts +3 -15
  9. package/dist/api-handlers/session/refresh-viability.js +5 -2
  10. package/dist/api-handlers/session/viability.d.ts +3 -8
  11. package/dist/api-handlers/session/viability.js +5 -2
  12. package/dist/auth/better-auth-exposure.d.ts +11 -0
  13. package/dist/auth/better-auth-exposure.js +73 -0
  14. package/dist/auth/better-auth.js +13 -1
  15. package/dist/auth/route-config.js +9 -0
  16. package/dist/lib/no-store.d.ts +13 -0
  17. package/dist/lib/no-store.js +35 -0
  18. package/dist/routes/auth/session.d.ts +2 -9
  19. package/dist/routes/auth/session.js +5 -2
  20. package/dist/routes/auth/viability.d.ts +3 -54
  21. package/dist/routes/auth/viability.js +5 -2
  22. package/dist/routes/health.d.ts +1 -0
  23. package/dist/routes/health.js +16 -0
  24. package/dist/routes/mfa/methods.d.ts +2 -2
  25. package/dist/routes/mfa/methods.js +3 -1
  26. package/dist/routes/passkeys/list.d.ts +2 -10
  27. package/dist/routes/passkeys/list.js +4 -2
  28. package/dist/routes/session/refresh-viability.d.ts +2 -1
  29. package/dist/routes/session/refresh-viability.js +4 -2
  30. package/dist/utils/api.d.ts +33 -6
  31. package/dist/utils/api.js +35 -2
  32. package/package.json +11 -1
  33. package/src/api-handlers/account/profile.ts +5 -1
  34. package/src/api-handlers/anon/preferences.ts +5 -4
  35. package/src/api-handlers/health.ts +53 -0
  36. package/src/api-handlers/session/refresh-viability.ts +5 -1
  37. package/src/api-handlers/session/viability.ts +6 -2
  38. package/src/auth/better-auth-exposure.ts +69 -0
  39. package/src/auth/better-auth.ts +15 -1
  40. package/src/auth/route-config.ts +9 -0
  41. package/src/lib/no-store.ts +30 -0
  42. package/src/routes/auth/session.ts +5 -1
  43. package/src/routes/auth/viability.ts +6 -2
  44. package/src/routes/health.ts +14 -0
  45. package/src/routes/mfa/methods.ts +3 -1
  46. package/src/routes/passkeys/list.ts +5 -1
  47. package/src/routes/session/refresh-viability.ts +5 -1
  48. package/src/utils/api.ts +50 -8
@@ -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)());
@@ -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.13.1",
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",
@@ -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();
@@ -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
+ }
@@ -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);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * PAY-2592 slice 1: the exposure guard for better-auth GHSA-965c-763c-88jm (critical, CVSS 9.1; affected >=1.4.0-beta.18 <1.7.7).
3
+ *
4
+ * "OAuth state can be used as a Magic Link to sign in as another user." It needs ALL of: the Magic Link plugin enabled, an OAuth provider, and OAuth state
5
+ * stored with `account.storeStateStrategy: "database"` (better-auth's default when a database OR secondaryStorage exists). This package has secondaryStorage,
6
+ * so what keeps it unexposed is, today:
7
+ * 1. `account.storeStateStrategy: 'cookie'` is pinned in better-auth.ts (the advisory: cookie-backed state blocks the path), and
8
+ * 2. the magic-link plugin registers ONLY when a consumer passes `opts.magicLink`.
9
+ * Nothing in this package fixes the flaw; the fix is better-auth >= 1.7.7 (PAY-2592, spike pending). Until then a consumer that turns the plugin on is told, loudly,
10
+ * once per process.
11
+ *
12
+ * test/next-mvp-pay-2592-exposure-pins.test.cjs holds both conditions and this warning.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ export const MAGIC_LINK_ADVISORY = 'GHSA-965c-763c-88jm';
18
+ export const MAGIC_LINK_FIX_VERSION = '1.7.7';
19
+
20
+ /** The installed better-auth version, read from its own package.json (the package does not export './package.json'), or null when it cannot be found. */
21
+ export function installedBetterAuthVersion(): string | null {
22
+ try {
23
+ let dir = dirname(require.resolve('better-auth'));
24
+ for (let i = 0; i < 8; i++) {
25
+ try {
26
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string };
27
+ if (pkg.name === 'better-auth' && typeof pkg.version === 'string') return pkg.version;
28
+ } catch {
29
+ /* no package.json at this level */
30
+ }
31
+ const up = dirname(dir);
32
+ if (up === dir) break;
33
+ dir = up;
34
+ }
35
+ } catch {
36
+ /* unresolvable */
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** True when `version` is strictly below `floor` (numeric major.minor.patch; a pre-release suffix is ignored). An unparseable version is NOT proven fixed, so it counts as below. */
42
+ export function versionBelow(version: string | null, floor: string): boolean {
43
+ const parse = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1, 4).map(Number) ?? null;
44
+ const a = version ? parse(version) : null;
45
+ const b = parse(floor);
46
+ if (!a || !b) return true;
47
+ for (let i = 0; i < 3; i++) {
48
+ if (a[i] !== b[i]) return a[i] < b[i];
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Warn when the magic-link plugin is enabled on a better-auth below the fix. Returns whether it warned (so a test can assert it). `warn` and `version` are
55
+ * injectable for the same reason.
56
+ */
57
+ export function warnIfMagicLinkBelowFix(
58
+ magicLinkEnabled: boolean,
59
+ warn: (msg: string) => void = (m) => console.warn(m),
60
+ version: string | null = installedBetterAuthVersion(),
61
+ ): boolean {
62
+ if (!magicLinkEnabled) return false;
63
+ if (!versionBelow(version, MAGIC_LINK_FIX_VERSION)) return false;
64
+ warn(
65
+ `[BETTER_AUTH] WARNING ${MAGIC_LINK_ADVISORY} (critical): the magic-link plugin is enabled on better-auth ${version ?? '(version unknown)'}, below the fix ${MAGIC_LINK_FIX_VERSION}. ` +
66
+ `This package keeps account.storeStateStrategy='cookie', which blocks the attack; do NOT change that while magic link is on, and upgrade better-auth (PAY-2592).`,
67
+ );
68
+ return true;
69
+ }
@@ -34,6 +34,11 @@ import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
34
34
  import { SESSION_TTL } from '../lib/session-store';
35
35
  import { tokenLogId } from '../lib/token-log-id';
36
36
  import { errForLog } from '../lib/log-safe';
37
+ import { warnIfMagicLinkBelowFix } from './better-auth-exposure';
38
+ import { noStore } from '../lib/no-store';
39
+
40
+ /** PAY-2592: the magic-link advisory warning fires once per process. */
41
+ let magicLinkAdvisoryWarned = false;
37
42
 
38
43
  /**
39
44
  * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
@@ -589,6 +594,13 @@ export function createBetterAuthInstance(
589
594
  assertAppSlugMatchesIdp(idpConfig.clientSlug);
590
595
  const appSlug = idpConfig.clientSlug || getAppSlug();
591
596
 
597
+ // PAY-2592 (GHSA-965c-763c-88jm): a consumer that enables the magic-link plugin on a better-auth below 1.7.7 is told, once per process. What keeps the
598
+ // package unexposed is account.storeStateStrategy='cookie' below plus the plugin being opt-in; see better-auth-exposure.ts.
599
+ if (opts.magicLink && !magicLinkAdvisoryWarned) {
600
+ magicLinkAdvisoryWarned = true;
601
+ warnIfMagicLinkBelowFix(true);
602
+ }
603
+
592
604
  // 185121 line 1: name the secret this instance PINNED, as a hash, never the
593
605
  // secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
594
606
  // caused by a divergent secret is only diagnosable if every instance
@@ -1596,7 +1608,7 @@ export function authErrorCodeFromLocation(location: string): string | null {
1596
1608
  * ```
1597
1609
  */
1598
1610
  export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
1599
- return async function GET(request: Request): Promise<Response> {
1611
+ const get = async function GET(request: Request): Promise<Response> {
1600
1612
  // Card 138635: establish the request-scoped carrier for the provider's raw
1601
1613
  // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
1602
1614
  // hook that sees the token) and the exchange below (which forwards it).
@@ -1711,4 +1723,6 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1711
1723
  return response;
1712
1724
  });
1713
1725
  };
1726
+ // PAY-2994: every Better Auth GET answer (session, OAuth callbacks, redirects) is per-user and may set cookies; never cached.
1727
+ return noStore(get);
1714
1728
  }
@@ -92,6 +92,15 @@ const defaultUnauthenticatedRoutes: UnauthenticatedRouteConfig[] = [
92
92
  allowDuringCircuitBreakerOpen: true,
93
93
  requiresRateLimit: false
94
94
  },
95
+ {
96
+ // PAY-2804: the exact path. '/api/health/*' matches startsWith('/api/health/'), which the bare
97
+ // '/api/health' (the ALB check) does not, so without this entry it sat behind sign-in.
98
+ pattern: '/api/health',
99
+ description: 'Health check endpoint (ALB target; anonymous, state only)',
100
+ allowDuringCircuitBreakerOpen: true,
101
+ // An ALB check comes from few IPs: a rate limit here would answer 429 and get tasks pulled (NightHawk 89179).
102
+ requiresRateLimit: false
103
+ },
95
104
  {
96
105
  pattern: '/api/health/*',
97
106
  description: 'Health check endpoints',
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cache-Control: no-store for GET routes whose answer carries a person's, a tenant's or a session's data (PAY-2994).
3
+ *
4
+ * WHY: the portal's BFF no-store ratchet (testing/qa/bff-no-store-ratchet.js) found 8 GET routes re-exported from this package that sent no
5
+ * Cache-Control. Their answers carry per-user data and some carry tokens, so a browser or an intermediate cache must never keep them.
6
+ *
7
+ * `noStore(handler)` wraps a route handler and sets `Cache-Control: no-store` on EVERY response it returns (errors and redirects too: nothing
8
+ * a session route says is worth caching). It REPLACES an existing Cache-Control, never merges, so a looser value cannot survive. A Response
9
+ * whose headers are immutable (a fetch() result, Response.redirect()) is rebuilt with the same status, body and headers (Set-Cookie kept).
10
+ */
11
+ export const NO_STORE_VALUE = 'no-store';
12
+
13
+ export function withNoStoreHeader(res: Response): Response {
14
+ // Not a Response (no headers object): hand it back untouched. A Next route always returns a Response, so this only keeps a hand-made
15
+ // test double from being turned into an empty Response.
16
+ if (!res || typeof res !== 'object' || !(res as { headers?: unknown }).headers) return res;
17
+ try {
18
+ res.headers.set('Cache-Control', NO_STORE_VALUE);
19
+ return res;
20
+ } catch {
21
+ // Immutable headers (TypeError). Rebuild: same status, statusText, body and every header; Headers copies Set-Cookie entries intact.
22
+ const headers = new Headers(res.headers);
23
+ headers.set('Cache-Control', NO_STORE_VALUE);
24
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
25
+ }
26
+ }
27
+
28
+ export function noStore<A extends unknown[], R extends Response>(handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<Response> {
29
+ return async (...args: A) => withNoStoreHeader(await handler(...args));
30
+ }
@@ -13,6 +13,7 @@
13
13
  * @since auth-ready-v2
14
14
  */
15
15
 
16
+ import { noStore } from '../../lib/no-store';
16
17
  import { NextRequest, NextResponse } from 'next/server';
17
18
  import { getSession as getBetterAuthSession } from '../../server/auth';
18
19
  import { getSession as getRedisSession, updateSession } from '../../lib/session-store';
@@ -27,7 +28,7 @@ import { tokenLogId } from '../../lib/token-log-id';
27
28
  * - Token expiry status
28
29
  * - Session validity
29
30
  */
30
- export async function GET(req: NextRequest) {
31
+ async function getHandler(req: NextRequest) {
31
32
  try {
32
33
  const authSession = await getBetterAuthSession(req);
33
34
 
@@ -121,6 +122,9 @@ export async function GET(req: NextRequest) {
121
122
  }
122
123
  }
123
124
 
125
+ /** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
126
+ export const GET = noStore(getHandler);
127
+
124
128
  /**
125
129
  * The fields this route USED to accept from the request body and must never accept
126
130
  * again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
@@ -24,6 +24,7 @@ import {
24
24
  import { getIDPClientConfig } from '../../lib/idp-client-config';
25
25
  import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
26
26
  import { tokenLogId } from '../../lib/token-log-id';
27
+ import { noStore } from '../../lib/no-store';
27
28
  import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
28
29
 
29
30
  /**
@@ -141,7 +142,7 @@ function resolveClientId(sessionClientId: string | undefined): string | null {
141
142
  * - needsRefresh: boolean - Whether a refresh is recommended
142
143
  * - expiresIn: number - Seconds until token expires
143
144
  */
144
- export async function GET(req: NextRequest) {
145
+ async function getHandler(req: NextRequest) {
145
146
  try {
146
147
  // PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
147
148
  // session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
@@ -525,4 +526,7 @@ export async function GET(req: NextRequest) {
525
526
  correlationId
526
527
  }, { status: 500 });
527
528
  }
528
- }
529
+ }
530
+
531
+ /** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
532
+ export const GET = noStore(getHandler);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Ready-to-use /api/health route: reports Redis reachability (PAY-2804).
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * // app/api/health/route.ts
7
+ * export const runtime = 'nodejs';
8
+ * export { GET } from '@payez/next-mvp/routes/health';
9
+ * ```
10
+ */
11
+ import { createHealthHandler } from '../api-handlers/health';
12
+ import { getRedis } from '../lib/redis';
13
+
14
+ export const GET = createHealthHandler(() => getRedis());
@@ -11,8 +11,10 @@
11
11
  */
12
12
  import { NextRequest } from 'next/server';
13
13
  import { GET as listMethods, DELETE as deleteMethod } from '../../api-handlers/mfa/methods';
14
+ import { noStore } from '../../lib/no-store';
14
15
 
15
- export const GET = listMethods;
16
+ /** PAY-2994: the user's MFA methods; never cached (noStore). */
17
+ export const GET = noStore(listMethods);
16
18
 
17
19
  type Ctx = { params: Promise<{ id: string }> };
18
20
 
@@ -7,4 +7,8 @@
7
7
  * export { GET } from '@payez/next-mvp/routes/passkeys/list';
8
8
  * ```
9
9
  */
10
- export { GET } from '../../api-handlers/passkeys/list';
10
+ import { GET as listPasskeys } from '../../api-handlers/passkeys/list';
11
+ import { noStore } from '../../lib/no-store';
12
+
13
+ /** PAY-2994: the signed-in user's passkeys; never cached (noStore). */
14
+ export const GET = noStore(listPasskeys);