@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog — @payez/next-mvp
2
2
 
3
+ ## 4.14.0 (to be published under dist-tag `next` only, after 4.13.1; `latest` stays 4.8.9 until Jon says otherwise)
4
+
5
+ A minor: additive exports, plus one behaviour change in the generated-app credential sign-in (listed first). Built from GitHub `keelsecurity/keelauth-next` main `33c188e0`; this release commit changes only this file and the version. Consumers keep their own pins.
6
+
7
+ - **CHANGE (PAY-3083 / PAY-2938, AUTH-045): a first sign-in on an app with no authenticator now reaches enrolment instead of failing.** When the second-factor step answers with an access token and no refresh token, `api-handlers/auth/credential-login` no longer answers 502. It answers 200 in both cases below, never mints a session, and never echoes or logs the token. **(1) The IdP says `next_step: 'enroll_mfa'` (the enrolment-scoped answer after a verified email code, and the default when the IdP names no step):** the answer is `{ success: false, requiresTwoFactor: true, nextStep: 'enroll_mfa', enrolmentRequired: true }`, parks the token in an httpOnly cookie `payez_enrol` (SameSite=Strict, path `/`, 600 s) that only the two new routes below read; it expires the `adm2fa` and `adm2fa_lane` cookies. **(2) The IdP says `next_step: 'mfa_verify'`:** the answer passes that step through as `nextStep: 'mfa_verify'` with NO `enrolmentRequired` flag, NO `payez_enrol` cookie, and the `adm2fa` and `adm2fa_lane` cookies are left in place (the sign-in form continues at the code step). A refresh-only or empty success is still a 502. TOTP enrolment is per client (per app), by design.
8
+ - **ADDITIVE: new exports** `./routes/mfa/first-enrol-enroll` and `./routes/mfa/first-enrol-confirm` (POST routes that read ONLY the `payez_enrol` cookie, accept JSON only, answer 401 `SIGN_IN_AGAIN` with no cookie, allow 5 attempts per cookie (the 6th is 429 and clears it), and on confirm return the recovery codes and clear the cookie; no session is minted), and `./dist/pages/first-enroll` (the page: QR, the key as selectable text under "Enter this code manually:", a sentence saying the authenticator is for this app only, the recovery codes, then "sign in again").
9
+ - **ADDITIVE: `CredentialSignInForm` takes an optional `firstEnrolHref`**, and `credential-sign-in-client` has a new result kind `enrol_required` (returned only when the answer carries `enrolmentRequired: true` with `nextStep: 'enroll_mfa'`). With the prop set the form navigates there; **without it the form shows "This app has no authenticator setup page, so sign-in cannot continue. Tell the app owner." and does not loop.** A consumer that does not adopt it sees that sentence instead of the old silent loop. Generated apps from `@payez/cli` mount the routes and page and pass the prop (CLI release follows this one).
10
+ - Tests: `test/credential-login.test.cjs`, `credential-sign-in-client.test.cjs`, `first-enrol-core.test.cjs`, `first-enrol-routes.test.cjs`, `first-enrol-panel.test.cjs` (with in-memory mutants, `test/lib/compile-mutant.cjs`).
11
+
12
+ **Also in this release (additive, BAPert 88395/88454):**
13
+
14
+ - **ADDITIVE (MINOR when released): SMS-first sign-up learns the `EMAIL_CONFIRMATION_REQUIRED` step (BAPert 88395 #2, 88454 #3; NextPert 88471).** The server (door 3b) now answers `verify-sms-code` with 200 success, `next_step = EMAIL_CONFIRMATION_REQUIRED` and NO reset token when the email is still unconfirmed. Before this, `accountApi.verifyOnboardingSmsCode` returned `{ success: true, resetToken: '' }` for that answer, which a consumer reads as "verified" and then calls set-password with an EMPTY token. Now: (a) the result type is the exported `SmsCodeVerification` and gains an OPTIONAL `nextStep` (`nextStep` ALONE is read from the nested shapes AND from a flat body; `reset_token`, `email` and `client_key` are still read from the nested shapes only, as before, so a consumer behind a proxy that forwards a flat body gets no token and the helper then says `more_steps`, the safe direction; absent / null / '' = no step; any other value, including `false`, `0` and a non-string, is kept as a non-empty UNRECOGNISED string, never dropped, so a server that means "no step" must send nothing or null); (b) new pure `interpretSmsVerification(result)` returns `verified` (only with a non-empty reset token and no step), `email_confirmation_required` (the package sentence "Your phone is verified. Confirm your email to finish signing up.", no error styling), `more_steps` ("One more step is needed to finish signing up." for ANY other step, and for success with an empty reset token) or `not_verified`; `success: false` WINS over any step (`not_verified`, so a failed answer never says "Your phone is verified"), and for every other answer it tests `nextStep` BEFORE the token; the sentences are the package's own, never the server's text. **The method itself still returns the server's `success` unchanged: a consumer MUST use `interpretSmsVerification` (or test `nextStep` first) and must not treat `success` alone as "verified", because `success: true` with an empty `resetToken` is what the server now sends for the unconfirmed-email case.** No breaking type change: a consumer that ignores `nextStep` compiles unchanged (test S6), and the email variant `verifyOnboardingCode` is untouched. **A consumer must adopt `interpretSmsVerification` (or branch on `nextStep` itself) to show the new screen; until it does, it behaves as before.** There is no SMS-first page in this package: the screen lives in the consuming sites, so the deploy of the server change (PR 85) waits on every live consumer taking this release. Tests: `test/sms-next-step.test.cjs` (32), `test/sms-next-step-mutants.test.cjs` (9 hand mutants, one per branch).
15
+
16
+ ## 4.13.1 (to be published under dist-tag `next` only, after 4.13.0; `latest` stays 4.8.9 until Jon says otherwise)
17
+
18
+ A patch: ONE change, the PAY-2994 no-store fix for 8 GET routes (re-cut on GitHub from DevOps PR 1757, BAPert 92969; the DevOps repo is frozen).
19
+
20
+ - **FIX (PAY-2994): eight GET routes now answer `Cache-Control: no-store` on every response.** The portal's BFF no-store ratchet found that these re-exported routes sent no Cache-Control, though their answers carry per-user data and some carry tokens, so a browser or an intermediate cache could keep them. New internal `lib/no-store` (`noStore(handler)`): it sets `no-store` on whatever the handler returns (errors and redirects too), REPLACES a looser existing value, rebuilds a Response whose headers are immutable (e.g. `Response.redirect`) with status, body and every `Set-Cookie` intact, passes the handler's arguments through, and hands a non-Response back untouched. Wrapped: `api-handlers/account/profile` GET, `api-handlers/anon/preferences` GET, `auth/better-auth` `createAuthGetHandler`, `routes/auth/session` GET, `routes/mfa/methods` GET, `routes/passkeys/list` GET, `routes/session/refresh-viability` GET, `routes/auth/viability` GET; and, as the same data under a second export path (NightHawk 92964 Low 1), `api-handlers/session/viability` GET and `api-handlers/session/refresh-viability` GET. No other behaviour changes. Package test `test/next-mvp-pay-2994-no-store.test.cjs` calls the REAL compiled GET of each and a mutant (wrapper removed) goes red.
21
+ - **NOT covered (follow-up, so the next ratchet run is expected, NightHawk 92964 Low 2):** GET answers in `api-handlers/auth/status`, `api-handlers/auth/validate`, `routes/auth/session-reset`, `routes/auth/settings` and `api-handlers/oauth/resume` (a redirect that sets cookies) still send no `no-store`. They were not in the ratchet's finding.
22
+
3
23
  ## 4.13.0 (2026-11-05; published under dist-tag `next` only, after 4.12.1; `latest` stays 4.8.9 until Jon says otherwise)
4
24
 
5
25
  A minor. Numbered 4.13.0 because everything new is additive or opt-in and no export was removed (two exports were added: `./components/mfa/TotpEnrollPanel` and `./dist/pages/enroll`), but three changes alter behaviour for every consumer and are listed first so nobody is surprised. Built from GitHub `keelsecurity/keelauth-next` main (the code is e0ccb0b9; this release commit changes only this file and the version). Base: 4.12.1 (PR 1500 only). PR numbers are the GitHub-era numbers where the PR was opened after the cutover, else the ADO number. Consumers keep their own pins: nothing here bumps or pins any consumer, and the portal stays on 4.12.1 until its owner says otherwise.
@@ -42,6 +62,7 @@ Not covered: Safari/Firefox (Chromium only), non-browser clients and browsers th
42
62
 
43
63
  ## Earlier entries now rolled into 4.13.0 (previously "Unreleased: PAY-2568 / PAY-2279, draft PR; no release, no pin")
44
64
 
65
+
45
66
  - **CHANGE: a failed credential sign-in shows a fixed sentence chosen by a stable code, never the server text.** `lib/sign-in-failure` is one closed table: `invalid_credentials` ("Incorrect email or password.", wrong password, unknown account and any other refusal, identical), `not_a_member` (the IdP code `NotAMemberOfThisApp` at any HTTP status - the public door relays it as 400 - shown only to someone who proved the credentials), `too_many_attempts` (429, `AccountLocked`, `UserLockedOut`, one sentence for all). `/api/auth/admin-login` now answers `code` + the fixed sentence in `error`; the browser client takes the sentence from the table by code and ignores the body text (an unmapped refusal gets one generic sentence, `sign_in_failed`). Each rendered error carries `data-error-code` (form and login page). Allowlisted policy refusals (SsoRequired, InvalidRequest) still show their detail under code `policy_message`, as a plain text node, at most 200 characters (BAPert 85432); no other server text reaches the page. **What changes for a caller:** the 429 sentence and the `?error=CredentialsSignin` sentence changed; a custom UI that read `body.error` still gets a fixed sentence there. Rows: `test/signin-failure-copy.test.cjs` S1-S7 (RED on master, six mutants).
46
67
  - **FIX: the code step of a credential sign-in (wrong, unavailable, locked) shows its own sentence again (BAPert 85795).** The code-step branch of `api-handlers/auth/credential-login` sent no `code`, so after the change above every second-factor refusal fell to the generic "Could not sign in. Check your details and try again.", including the "problem on our side, not with your code" answer. The branch now sends `invalid_second_factor` ("That code is not correct. Check the code and try again."), `second_factor_unavailable` (the existing our-side sentence, with the Email-me-a-code offer kept) or `too_many_attempts`, chosen by the existing `classifyTwofaRejection` and never from IdP wording. The lockout sentence now states the window: `Too many sign-in attempts. Try again in N minutes, or reset your password.`, N = the server's `retryAfter` (seconds) rounded up to whole minutes and clamped to 1..60; with no usable number it stays the plain sentence (only a number is ever printed). The password-step 429 no longer invents a `retryAfter` of 60 when the IdP sent no Retry-After. A plain IdP 429 on the code step (a lane rate limit, not a `locked` lockout) has its own code `second_factor_rate_limited`: "Too many attempts. Try again in N minutes." (same N rule; plain fallback "Too many attempts. Wait a few minutes and try again."), so it no longer tells a user to reset their password; it makes no claim that the code is still valid (unproven here), and `twofa-rejection` no longer invents a 60 s `retryAfter`. Sentences lost by the 1719 regression in this lane: the wrong-code sentence, the our-side-fault sentence (with its Email-me-a-code offer text) and the lockout window. Test: `test/second-factor-copy.test.cjs` (T1-T15, mutants RED).
47
68
 
@@ -1,41 +1,4 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- /**
3
- * Account Profile API Handler
4
- *
5
- * GET /api/account/profile - Get user profile
6
- * PUT /api/account/profile - Update user profile
7
- *
8
- * WHY THIS WAS REWRITTEN (2026-08-02, QAPert defect #7)
9
- * Reported as "/api/account/profile 401s on the same cookie masked-info
10
- * accepts (200)". Two endpoints disagreeing about one cookie meant two auth
11
- * implementations, and they were:
12
- *
13
- * masked-info.ts idpFetchJSON(req, ...) resolves the caller's token
14
- * from the server-side session
15
- * profile.ts req.headers.get('authorization') required an INBOUND bearer
16
- *
17
- * A browser sends a session COOKIE and no Authorization header, so the old
18
- * profile.ts 401'd EVERY browser request by construction. Nothing was revoked
19
- * and no token was stale — the handler demanded a credential the app does not
20
- * use.
21
- *
22
- * THE OLD AUTH CHECK WAS A VENEER. It tested that the header was PRESENT, never
23
- * that it was valid, then forwarded it upstream unexamined. Any string at all
24
- * got past it and the IDP did the real work. It looked like enforcement and
25
- * enforced nothing.
26
- *
27
- * WHAT THE WORKAROUND COST US: consumers could not call this with a cookie, so
28
- * the graft's useProfile hook fetched the raw IDP access token from
29
- * /api/session/idp-token INTO BROWSER JAVASCRIPT and re-sent it as a bearer.
30
- * That defeats the reason idpFetchJSON exists — it keeps the token server-side
31
- * precisely so client-side script can never hold it. The bearer-only handler
32
- * did not merely fail; it pushed its callers into exposing the token.
33
- *
34
- * Switching to idpFetchJSON makes the cookie work, so the hook's token round
35
- * trip can be deleted and /api/session/idp-token drops to a single deliberate
36
- * caller: the jwt-inspect debug page.
37
- *
38
- * @package @payez/next-mvp
39
- */
40
- export declare function GET(req: NextRequest): Promise<NextResponse<any>>;
2
+ /** PAY-2994: the profile is one person's data; never cached (noStore). */
3
+ export declare const GET: (req: NextRequest) => Promise<Response>;
41
4
  export declare function PUT(req: NextRequest): Promise<NextResponse<any>>;
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GET = GET;
3
+ exports.GET = void 0;
4
4
  exports.PUT = PUT;
5
5
  const server_1 = require("next/server");
6
6
  const idp_fetch_1 = require("../../lib/idp-fetch");
7
7
  const env_1 = require("../../config/env");
8
+ const no_store_1 = require("../../lib/no-store");
8
9
  /**
9
10
  * Account Profile API Handler
10
11
  *
@@ -43,7 +44,7 @@ const env_1 = require("../../config/env");
43
44
  *
44
45
  * @package @payez/next-mvp
45
46
  */
46
- async function GET(req) {
47
+ async function getProfile(req) {
47
48
  const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/profile`, {
48
49
  method: 'GET',
49
50
  headers: { 'Content-Type': 'application/json' },
@@ -61,6 +62,8 @@ async function GET(req) {
61
62
  }
62
63
  return server_1.NextResponse.json(result.json, { status: 200 });
63
64
  }
65
+ /** PAY-2994: the profile is one person's data; never cached (noStore). */
66
+ exports.GET = (0, no_store_1.noStore)(getProfile);
64
67
  async function PUT(req) {
65
68
  let body = '{}';
66
69
  try {
@@ -8,23 +8,10 @@
8
8
  */
9
9
  import { NextRequest, NextResponse } from 'next/server';
10
10
  import { AnonSessionPreferences } from '../../lib/anon-session';
11
- /**
12
- * GET handler - retrieves anonymous session preferences
13
- */
14
- export declare function GET(request: NextRequest): Promise<NextResponse<{
15
- success: boolean;
16
- data: {
17
- id: string;
18
- preferences: AnonSessionPreferences;
19
- metrics: import("../../lib/anon-session").AnonSessionMetrics;
20
- };
21
- }> | NextResponse<{
22
- success: boolean;
23
- error: string;
24
- }>>;
25
11
  /**
26
12
  * POST handler - updates anonymous session preferences
27
13
  */
14
+ export declare const GET: (request: NextRequest) => Promise<Response>;
28
15
  export declare function POST(request: NextRequest): Promise<NextResponse<{
29
16
  success: boolean;
30
17
  error: string;
@@ -8,17 +8,16 @@
8
8
  * Sets a cookie to track anonymous visitors and stores preferences in Redis.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.GET = GET;
11
+ exports.GET = void 0;
12
12
  exports.POST = POST;
13
13
  const server_1 = require("next/server");
14
14
  const headers_1 = require("next/headers");
15
15
  const anon_session_1 = require("../../lib/anon-session");
16
16
  const log_safe_1 = require("../../lib/log-safe");
17
+ const no_store_1 = require("../../lib/no-store");
17
18
  const COOKIE_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
18
- /**
19
- * GET handler - retrieves anonymous session preferences
20
- */
21
- async function GET(request) {
19
+ /** Retrieves anonymous session preferences. Exported as GET below, wrapped in noStore (PAY-2994). */
20
+ async function getPreferences(request) {
22
21
  try {
23
22
  const cookieStore = await (0, headers_1.cookies)();
24
23
  let anonId = cookieStore.get(anon_session_1.ANON_COOKIE_NAME)?.value;
@@ -53,6 +52,7 @@ async function GET(request) {
53
52
  /**
54
53
  * POST handler - updates anonymous session preferences
55
54
  */
55
+ exports.GET = (0, no_store_1.noStore)(getPreferences);
56
56
  async function POST(request) {
57
57
  try {
58
58
  const cookieStore = await (0, headers_1.cookies)();
@@ -52,6 +52,8 @@ const device_id_1 = require("../../lib/device-id");
52
52
  const twofa_rejection_1 = require("../../lib/twofa-rejection");
53
53
  const bearer_fetch_1 = require("../../lib/bearer-fetch");
54
54
  const log_safe_1 = require("../../lib/log-safe");
55
+ // 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).
56
+ const first_enrol_1 = require("../../lib/first-enrol");
55
57
  // `export const runtime = 'nodejs'` must be written LITERALLY in the app's route file (Next ignores a re-exported segment config).
56
58
  /**
57
59
  * The device id to forward to the IDP for THIS caller — card 240566.
@@ -325,6 +327,50 @@ async function POST(req) {
325
327
  const vresult = verifyData?.data?.result ?? verifyData?.data ?? verifyData;
326
328
  const vAccess = vresult?.access_token;
327
329
  const vRefresh = vresult?.refresh_token;
330
+ // PAY-3083 (AUTH-045): ACCESS WITHOUT REFRESH AT THE CODE TURN IS NOT A FAILURE - IT IS ENROLMENT.
331
+ //
332
+ // The IdP withholds the refresh token for a user who has no usable second factor on THIS client.
333
+ // TOTP enrolment is PER CLIENT (DotNetPert 99446 sec A, client 90717 / user 1175): the password turn
334
+ // answered Require2FA=true, IssueRefreshToken=false, step=enroll_mfa, and this verify turn then
335
+ // accepted the emailed code as CONTACT PROOF and issued an ENROLMENT-SCOPED access token, again with
336
+ // no refresh token. That is exactly what PAY-2798 requires of the IdP - the IdP is RIGHT.
337
+ //
338
+ // This branch used to read "access, no refresh" as an incomplete response and answer 502, so the
339
+ // developer who had just proved their contact was told the server was broken and could never reach
340
+ // the enrolment page: the 502 the field saw as
341
+ // '[ADMIN-LOGIN] verify response missing token { hasAccess: true, hasRefresh: false }'.
342
+ //
343
+ // Send them to ENROLMENT instead, in the ONE contract the door and the form already share for it
344
+ // (PAY-2630): requiresTwoFactor + nextStep 'enroll_mfa'. No session is minted - an enrolment-scoped
345
+ // token is not a signed-in session - and the token value is NEVER logged nor echoed (BAPert 99521 #2).
346
+ //
347
+ // Guard kept: no access token AT ALL (a refresh-only or empty body) is still a broken success and
348
+ // still 502 below - this widening is only for a genuine access-with-no-refresh answer.
349
+ if (vAccess && !vRefresh) {
350
+ const step = vresult?.next_step === 'enroll_mfa' || vresult?.next_step === 'mfa_verify' ? vresult.next_step : 'enroll_mfa';
351
+ const enrolling = step === 'enroll_mfa';
352
+ const answer = server_1.NextResponse.json({
353
+ success: false,
354
+ requiresTwoFactor: true,
355
+ acceptedFactors: acceptedFactorsFor('email'),
356
+ sentVia: null,
357
+ codeRequested: false,
358
+ emailSent: false,
359
+ nextStep: step,
360
+ // 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
361
+ // and looped. This flag is the ONLY signal that means "go enrol"; it is set on this answer and nowhere else.
362
+ ...(enrolling ? { enrolmentRequired: true } : {}),
363
+ }, { status: 200 });
364
+ if (enrolling) {
365
+ // No session exists for an enrolment-scoped token, and the enrolment page's actions need one, so the token rides in this httpOnly
366
+ // 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.
367
+ answer.cookies.set(first_enrol_1.ENROL_COOKIE, vAccess, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge: first_enrol_1.ENROL_COOKIE_MAX_AGE });
368
+ // The provisional bearer is spent: the enrolment token replaces it.
369
+ answer.cookies.set('adm2fa', '', { path: '/', maxAge: 0 });
370
+ answer.cookies.set('adm2fa_lane', '', { path: '/', maxAge: 0 });
371
+ }
372
+ return answer;
373
+ }
328
374
  if (!vAccess || !vRefresh) {
329
375
  console.error('[ADMIN-LOGIN] verify response missing tokens', { hasAccess: !!vAccess, hasRefresh: !!vRefresh });
330
376
  return server_1.NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
@@ -0,0 +1,5 @@
1
+ export interface HealthRedis {
2
+ ping(): Promise<unknown>;
3
+ }
4
+ export type RedisState = 'up' | 'down' | 'unknown';
5
+ export declare function createHealthHandler(getRedis: () => HealthRedis): () => Promise<Response>;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createHealthHandler = createHealthHandler;
4
+ /**
5
+ * /api/health handler (PAY-2804, AUTH-045; BAPert ruling 88065).
6
+ *
7
+ * Sessions are Redis-only. The body reports state ONLY: `redis: 'up' | 'down' | 'unknown'` and
8
+ * `status: 'ok' | 'degraded'`. No REDIS_URL, host, port, error text or stack, to any caller (anonymous route).
9
+ *
10
+ * The HTTP status is 200 even when Redis is down: this route is the ALB target-group check, and a 503
11
+ * there would make the ALB pull every task for an outage that replacing tasks cannot fix.
12
+ * HEALTH_REDIS_FAILS_STATUS=1 turns Redis-down into 503 as a READINESS setting for a deployer's OWN probe;
13
+ * it is default off and must never be set on a load-balancer health check.
14
+ */
15
+ const server_1 = require("next/server");
16
+ const PING_BUDGET_MS = 2000;
17
+ async function probe(redis) {
18
+ let timer;
19
+ try {
20
+ const timeout = new Promise((_, reject) => {
21
+ timer = setTimeout(() => reject(new Error('ping timeout')), PING_BUDGET_MS);
22
+ });
23
+ await Promise.race([redis.ping(), timeout]);
24
+ return 'up';
25
+ }
26
+ catch {
27
+ return 'down';
28
+ }
29
+ finally {
30
+ if (timer)
31
+ clearTimeout(timer);
32
+ }
33
+ }
34
+ function createHealthHandler(getRedis) {
35
+ return async function GET() {
36
+ let redis;
37
+ let client;
38
+ try {
39
+ client = getRedis();
40
+ }
41
+ catch {
42
+ client = undefined;
43
+ }
44
+ redis = client ? await probe(client) : 'unknown';
45
+ const failsStatus = process.env.HEALTH_REDIS_FAILS_STATUS === '1';
46
+ return server_1.NextResponse.json({ status: redis === 'up' ? 'ok' : 'degraded', redis }, { status: redis !== 'up' && failsStatus ? 503 : 200, headers: { 'Cache-Control': 'no-store' } });
47
+ };
48
+ }
@@ -0,0 +1,5 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { type EnrolStep } from '../../lib/first-enrol';
3
+ export declare function enrolStepPOST(step: EnrolStep): (req: NextRequest) => Promise<NextResponse>;
4
+ export declare const POST_ENROLL: (req: NextRequest) => Promise<NextResponse>;
5
+ export declare const POST_CONFIRM: (req: NextRequest) => Promise<NextResponse>;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST_CONFIRM = exports.POST_ENROLL = void 0;
4
+ exports.enrolStepPOST = enrolStepPOST;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const server_1 = require("next/server");
7
+ const idp_client_config_1 = require("../../lib/idp-client-config");
8
+ const client_key_1 = require("../../lib/client-key");
9
+ const mfa_json_guard_1 = require("../../lib/mfa-json-guard");
10
+ const first_enrol_1 = require("../../lib/first-enrol");
11
+ /**
12
+ * 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.
13
+ *
14
+ * WHO MAY CALL: the browser that holds the httpOnly enrolment cookie the credential door parked (ENROL_COOKIE). This is the ONLY module besides the
15
+ * 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
16
+ * holding nothing but this cookie (a gate row holds that). No cookie -> 401 SIGN_IN_AGAIN and no IdP call.
17
+ *
18
+ * 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
19
+ * way to mint one is added. The person signs in again with their password and the authenticator, through the same credential door as before.
20
+ *
21
+ * The token never leaves the server: not in a body, a URL, a log or a Referer. The IdP's prose is never relayed.
22
+ */
23
+ const digest = (s) => (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
24
+ const cookieOpts = (maxAge) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge });
25
+ function reply(status, body, clear) {
26
+ const res = server_1.NextResponse.json(body, { status, headers: mfa_json_guard_1.NO_STORE });
27
+ if (clear)
28
+ res.cookies.set(first_enrol_1.ENROL_COOKIE, '', cookieOpts(0));
29
+ return res;
30
+ }
31
+ function enrolStepPOST(step) {
32
+ return async function POST(req) {
33
+ const refused = (0, mfa_json_guard_1.refuseNonJson)(req);
34
+ if (refused)
35
+ return refused;
36
+ const token = req.cookies.get(first_enrol_1.ENROL_COOKIE)?.value;
37
+ if (!token)
38
+ return reply(401, { success: false, code: 'SIGN_IN_AGAIN' }, false);
39
+ if (!(0, first_enrol_1.attemptAllowed)(`${step}|ck|${digest(token)}`, first_enrol_1.LIMIT_PER_COOKIE))
40
+ return reply(429, { success: false, code: 'TOO_MANY', error: 'Too many attempts. Wait a few minutes and try again.' }, true);
41
+ const idp = process.env.IDP_URL;
42
+ let clientKey = '';
43
+ try {
44
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
45
+ clientKey = (0, client_key_1.resolveClientKey)(config, 'first-enrol').key ?? '';
46
+ }
47
+ catch {
48
+ return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
49
+ }
50
+ if (!idp || !clientKey)
51
+ return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
52
+ const body = await req.json().catch(() => null);
53
+ const r = await (0, first_enrol_1.runEnrolStep)({ fetchImpl: (u, i) => fetch(u, i), idp, clientKey }, { step, token, body });
54
+ return reply(r.status, r.body, r.cookie === 'clear');
55
+ };
56
+ }
57
+ exports.POST_ENROLL = enrolStepPOST('enroll');
58
+ exports.POST_CONFIRM = enrolStepPOST('confirm');
@@ -1,15 +1,3 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- export declare function GET(req: NextRequest): Promise<NextResponse<{
3
- canRefresh: boolean;
4
- reason: string;
5
- error: string;
6
- code: string;
7
- }> | NextResponse<{
8
- canRefresh: boolean;
9
- reason: string;
10
- sessionTokenId: string;
11
- }> | NextResponse<{
12
- canRefresh: boolean;
13
- reason: string;
14
- sessionTokenId: null;
15
- }>>;
1
+ import { NextRequest } from 'next/server';
2
+ /** 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. */
3
+ export declare const GET: (req: NextRequest) => Promise<Response>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GET = GET;
3
+ exports.GET = void 0;
4
4
  const server_1 = require("next/server");
5
5
  const session_store_1 = require("../../lib/session-store");
6
6
  const refresh_token_validator_1 = require("../../lib/refresh-token-validator");
@@ -8,8 +8,9 @@ const test_aware_get_token_1 = require("../../lib/test-aware-get-token");
8
8
  const logger_1 = require("../../config/logger");
9
9
  const token_log_id_1 = require("../../lib/token-log-id");
10
10
  const log_safe_1 = require("../../lib/log-safe");
11
+ const no_store_1 = require("../../lib/no-store");
11
12
  const auth_1 = require("../../server/auth");
12
- async function GET(req) {
13
+ async function getRefreshViability(req) {
13
14
  try {
14
15
  const sessionToken = req.nextUrl.searchParams.get('token');
15
16
  let finalSessionToken = sessionToken;
@@ -59,3 +60,5 @@ async function GET(req) {
59
60
  return server_1.NextResponse.json({ canRefresh: false, reason: 'check_error', error: 'Failed to check refresh viability', code: 'INTERNAL_ERROR', correlationId }, { status: 500 });
60
61
  }
61
62
  }
63
+ /** 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. */
64
+ exports.GET = (0, no_store_1.noStore)(getRefreshViability);
@@ -3,11 +3,6 @@
3
3
  *
4
4
  * This API route is called by the middleware to securely check if a session is valid.
5
5
  */
6
- import { NextRequest, NextResponse } from 'next/server';
7
- export declare function GET(req: NextRequest): Promise<NextResponse<{
8
- error: string;
9
- message: string;
10
- code: string;
11
- }> | NextResponse<{
12
- authenticated: boolean;
13
- }>>;
6
+ import { NextRequest } from 'next/server';
7
+ /** 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. */
8
+ export declare const GET: (req: NextRequest) => Promise<Response>;
@@ -5,7 +5,7 @@
5
5
  * This API route is called by the middleware to securely check if a session is valid.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.GET = GET;
8
+ exports.GET = void 0;
9
9
  const revocation_fields_1 = require("../../auth/revocation-fields");
10
10
  const server_1 = require("next/server");
11
11
  const session_store_1 = require("../../lib/session-store");
@@ -15,7 +15,8 @@ const idp_client_config_1 = require("../../lib/idp-client-config");
15
15
  const token_log_id_1 = require("../../lib/token-log-id");
16
16
  const sessions_1 = require("../../vibe/sessions");
17
17
  const log_safe_1 = require("../../lib/log-safe");
18
- async function GET(req) {
18
+ const no_store_1 = require("../../lib/no-store");
19
+ async function getViability(req) {
19
20
  try {
20
21
  // Ensure initialization is complete (auth signing secret resolved from IDP)
21
22
  if (!process.env.BETTER_AUTH_SECRET && !process.env.NEXTAUTH_SECRET) {
@@ -222,3 +223,5 @@ async function GET(req) {
222
223
  return server_1.NextResponse.json({ authenticated: false }, { status: 500 });
223
224
  }
224
225
  }
226
+ /** 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. */
227
+ exports.GET = (0, no_store_1.noStore)(getViability);
@@ -0,0 +1,11 @@
1
+ export declare const MAGIC_LINK_ADVISORY = "GHSA-965c-763c-88jm";
2
+ export declare const MAGIC_LINK_FIX_VERSION = "1.7.7";
3
+ /** 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. */
4
+ export declare function installedBetterAuthVersion(): string | null;
5
+ /** 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. */
6
+ export declare function versionBelow(version: string | null, floor: string): boolean;
7
+ /**
8
+ * 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
9
+ * injectable for the same reason.
10
+ */
11
+ export declare function warnIfMagicLinkBelowFix(magicLinkEnabled: boolean, warn?: (msg: string) => void, version?: string | null): boolean;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAGIC_LINK_FIX_VERSION = exports.MAGIC_LINK_ADVISORY = void 0;
4
+ exports.installedBetterAuthVersion = installedBetterAuthVersion;
5
+ exports.versionBelow = versionBelow;
6
+ exports.warnIfMagicLinkBelowFix = warnIfMagicLinkBelowFix;
7
+ /**
8
+ * 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).
9
+ *
10
+ * "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
11
+ * stored with `account.storeStateStrategy: "database"` (better-auth's default when a database OR secondaryStorage exists). This package has secondaryStorage,
12
+ * so what keeps it unexposed is, today:
13
+ * 1. `account.storeStateStrategy: 'cookie'` is pinned in better-auth.ts (the advisory: cookie-backed state blocks the path), and
14
+ * 2. the magic-link plugin registers ONLY when a consumer passes `opts.magicLink`.
15
+ * 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,
16
+ * once per process.
17
+ *
18
+ * test/next-mvp-pay-2592-exposure-pins.test.cjs holds both conditions and this warning.
19
+ */
20
+ const node_fs_1 = require("node:fs");
21
+ const node_path_1 = require("node:path");
22
+ exports.MAGIC_LINK_ADVISORY = 'GHSA-965c-763c-88jm';
23
+ exports.MAGIC_LINK_FIX_VERSION = '1.7.7';
24
+ /** 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. */
25
+ function installedBetterAuthVersion() {
26
+ try {
27
+ let dir = (0, node_path_1.dirname)(require.resolve('better-auth'));
28
+ for (let i = 0; i < 8; i++) {
29
+ try {
30
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, 'package.json'), 'utf8'));
31
+ if (pkg.name === 'better-auth' && typeof pkg.version === 'string')
32
+ return pkg.version;
33
+ }
34
+ catch {
35
+ /* no package.json at this level */
36
+ }
37
+ const up = (0, node_path_1.dirname)(dir);
38
+ if (up === dir)
39
+ break;
40
+ dir = up;
41
+ }
42
+ }
43
+ catch {
44
+ /* unresolvable */
45
+ }
46
+ return null;
47
+ }
48
+ /** 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. */
49
+ function versionBelow(version, floor) {
50
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1, 4).map(Number) ?? null;
51
+ const a = version ? parse(version) : null;
52
+ const b = parse(floor);
53
+ if (!a || !b)
54
+ return true;
55
+ for (let i = 0; i < 3; i++) {
56
+ if (a[i] !== b[i])
57
+ return a[i] < b[i];
58
+ }
59
+ return false;
60
+ }
61
+ /**
62
+ * 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
63
+ * injectable for the same reason.
64
+ */
65
+ function warnIfMagicLinkBelowFix(magicLinkEnabled, warn = (m) => console.warn(m), version = installedBetterAuthVersion()) {
66
+ if (!magicLinkEnabled)
67
+ return false;
68
+ if (!versionBelow(version, exports.MAGIC_LINK_FIX_VERSION))
69
+ return false;
70
+ warn(`[BETTER_AUTH] WARNING ${exports.MAGIC_LINK_ADVISORY} (critical): the magic-link plugin is enabled on better-auth ${version ?? '(version unknown)'}, below the fix ${exports.MAGIC_LINK_FIX_VERSION}. ` +
71
+ `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).`);
72
+ return true;
73
+ }
@@ -78,6 +78,10 @@ const single_flight_1 = require("../lib/single-flight");
78
78
  const session_store_1 = require("../lib/session-store");
79
79
  const token_log_id_1 = require("../lib/token-log-id");
80
80
  const log_safe_1 = require("../lib/log-safe");
81
+ const better_auth_exposure_1 = require("./better-auth-exposure");
82
+ const no_store_1 = require("../lib/no-store");
83
+ /** PAY-2592: the magic-link advisory warning fires once per process. */
84
+ let magicLinkAdvisoryWarned = false;
81
85
  /**
82
86
  * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
83
87
  * to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
@@ -489,6 +493,12 @@ function buildBetterAuthProviders(config) {
489
493
  function createBetterAuthInstance(idpConfig, opts = {}) {
490
494
  (0, app_slug_1.assertAppSlugMatchesIdp)(idpConfig.clientSlug);
491
495
  const appSlug = idpConfig.clientSlug || (0, app_slug_1.getAppSlug)();
496
+ // 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
497
+ // package unexposed is account.storeStateStrategy='cookie' below plus the plugin being opt-in; see better-auth-exposure.ts.
498
+ if (opts.magicLink && !magicLinkAdvisoryWarned) {
499
+ magicLinkAdvisoryWarned = true;
500
+ (0, better_auth_exposure_1.warnIfMagicLinkBelowFix)(true);
501
+ }
492
502
  // 185121 line 1: name the secret this instance PINNED, as a hash, never the
493
503
  // secret. 183047's surviving fork is HMAC-fail vs record-miss; an HMAC-fail
494
504
  // caused by a divergent secret is only diagnosable if every instance
@@ -1410,7 +1420,7 @@ function authErrorCodeFromLocation(location) {
1410
1420
  * ```
1411
1421
  */
1412
1422
  function createAuthGetHandler(loginPath = '/account-auth/login') {
1413
- return async function GET(request) {
1423
+ const get = async function GET(request) {
1414
1424
  // Card 138635: establish the request-scoped carrier for the provider's raw
1415
1425
  // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
1416
1426
  // hook that sees the token) and the exchange below (which forwards it).
@@ -1523,4 +1533,6 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1523
1533
  return response;
1524
1534
  });
1525
1535
  };
1536
+ // PAY-2994: every Better Auth GET answer (session, OAuth callbacks, redirects) is per-user and may set cookies; never cached.
1537
+ return (0, no_store_1.noStore)(get);
1526
1538
  }
@@ -75,6 +75,15 @@ const defaultUnauthenticatedRoutes = [
75
75
  allowDuringCircuitBreakerOpen: true,
76
76
  requiresRateLimit: false
77
77
  },
78
+ {
79
+ // PAY-2804: the exact path. '/api/health/*' matches startsWith('/api/health/'), which the bare
80
+ // '/api/health' (the ALB check) does not, so without this entry it sat behind sign-in.
81
+ pattern: '/api/health',
82
+ description: 'Health check endpoint (ALB target; anonymous, state only)',
83
+ allowDuringCircuitBreakerOpen: true,
84
+ // An ALB check comes from few IPs: a rate limit here would answer 429 and get tasks pulled (NightHawk 89179).
85
+ requiresRateLimit: false
86
+ },
78
87
  {
79
88
  pattern: '/api/health/*',
80
89
  description: 'Health check endpoints',
@@ -33,7 +33,12 @@ interface Props {
33
33
  * default would 404 a generated app's user right after a good sign-in).
34
34
  */
35
35
  enrollHref?: string;
36
+ /**
37
+ * AUTH-045: where a person is sent when the IdP says they must ENROL a factor and they have NO session (the door parked an httpOnly enrolment cookie).
38
+ * Distinct from enrollHref (which needs a session). NO DEFAULT: without it the form says so instead of redrawing the code box.
39
+ */
40
+ firstEnrolHref?: string;
36
41
  }
37
42
  export declare function resolveDestination(explicit?: string): string;
38
- export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref }: Props): import("react/jsx-runtime").JSX.Element;
43
+ export declare function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword, enrollHref, firstEnrolHref }: Props): import("react/jsx-runtime").JSX.Element;
39
44
  export default CredentialSignInForm;