@payez/next-mvp 4.12.1 → 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 (115) hide show
  1. package/CHANGELOG.md +57 -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 +28 -4
  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/totp-confirm.js +8 -4
  10. package/dist/api-handlers/mfa/totp-enroll.js +8 -4
  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 +11 -4
  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.d.ts +6 -0
  18. package/dist/auth/better-auth.js +42 -3
  19. package/dist/auth/route-config.js +9 -0
  20. package/dist/auth/session-reset-link.d.ts +32 -0
  21. package/dist/auth/session-reset-link.js +45 -1
  22. package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
  23. package/dist/components/auth/CredentialSignInForm.js +13 -4
  24. package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
  25. package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
  26. package/dist/components/mfa/QrCode.d.ts +6 -0
  27. package/dist/components/mfa/QrCode.js +14 -0
  28. package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
  29. package/dist/components/mfa/TotpEnrollPanel.js +117 -0
  30. package/dist/edge/middleware.d.ts +1 -1
  31. package/dist/edge/middleware.js +2 -1
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/lib/credential-sign-in-client.d.ts +26 -1
  35. package/dist/lib/credential-sign-in-client.js +32 -6
  36. package/dist/lib/idp-client-config.js +4 -1
  37. package/dist/lib/login-error-message.d.ts +3 -0
  38. package/dist/lib/login-error-message.js +12 -1
  39. package/dist/lib/mfa-json-guard.d.ts +12 -0
  40. package/dist/lib/mfa-json-guard.js +19 -0
  41. package/dist/lib/nextauth-secret.js +3 -1
  42. package/dist/lib/no-store.d.ts +13 -0
  43. package/dist/lib/no-store.js +35 -0
  44. package/dist/lib/payez-client-secret.d.ts +14 -0
  45. package/dist/lib/payez-client-secret.js +41 -0
  46. package/dist/lib/qrcode.d.ts +22 -0
  47. package/dist/lib/qrcode.js +362 -0
  48. package/dist/lib/sign-in-failure.d.ts +35 -0
  49. package/dist/lib/sign-in-failure.js +85 -0
  50. package/dist/lib/twofa-rejection.d.ts +2 -0
  51. package/dist/lib/twofa-rejection.js +5 -2
  52. package/dist/middleware/create-middleware.d.ts +24 -0
  53. package/dist/middleware/create-middleware.js +56 -9
  54. package/dist/middleware/prefetch-guard-status.d.ts +21 -0
  55. package/dist/middleware/prefetch-guard-status.js +42 -0
  56. package/dist/pages/enroll/page.d.ts +7 -0
  57. package/dist/pages/enroll/page.js +15 -0
  58. package/dist/pages/login/page.js +1 -1
  59. package/dist/routes/auth/session-reset.d.ts +11 -1
  60. package/dist/routes/auth/session-reset.js +38 -13
  61. package/dist/routes/auth/session.d.ts +2 -9
  62. package/dist/routes/auth/session.js +5 -2
  63. package/dist/routes/auth/viability.d.ts +3 -54
  64. package/dist/routes/auth/viability.js +16 -4
  65. package/dist/routes/health.d.ts +1 -0
  66. package/dist/routes/health.js +16 -0
  67. package/dist/routes/mfa/methods.d.ts +2 -2
  68. package/dist/routes/mfa/methods.js +3 -1
  69. package/dist/routes/passkeys/list.d.ts +2 -10
  70. package/dist/routes/passkeys/list.js +4 -2
  71. package/dist/routes/session/refresh-viability.d.ts +2 -1
  72. package/dist/routes/session/refresh-viability.js +4 -2
  73. package/dist/utils/api.d.ts +33 -6
  74. package/dist/utils/api.js +35 -2
  75. package/package.json +21 -1
  76. package/src/api-handlers/account/profile.ts +5 -1
  77. package/src/api-handlers/anon/preferences.ts +5 -4
  78. package/src/api-handlers/auth/credential-login.ts +29 -4
  79. package/src/api-handlers/health.ts +53 -0
  80. package/src/api-handlers/mfa/totp-confirm.ts +7 -4
  81. package/src/api-handlers/mfa/totp-enroll.ts +7 -4
  82. package/src/api-handlers/session/refresh-viability.ts +5 -1
  83. package/src/api-handlers/session/viability.ts +12 -4
  84. package/src/auth/better-auth-exposure.ts +69 -0
  85. package/src/auth/better-auth.ts +42 -3
  86. package/src/auth/route-config.ts +9 -0
  87. package/src/auth/session-reset-link.ts +47 -0
  88. package/src/components/auth/CredentialSignInForm.tsx +21 -3
  89. package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
  90. package/src/components/mfa/QrCode.tsx +24 -0
  91. package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
  92. package/src/edge/middleware.ts +1 -1
  93. package/src/index.ts +1 -1
  94. package/src/lib/credential-sign-in-client.ts +48 -8
  95. package/src/lib/idp-client-config.ts +5 -1
  96. package/src/lib/login-error-message.ts +13 -1
  97. package/src/lib/mfa-json-guard.ts +19 -0
  98. package/src/lib/nextauth-secret.ts +3 -1
  99. package/src/lib/no-store.ts +30 -0
  100. package/src/lib/payez-client-secret.ts +39 -0
  101. package/src/lib/qrcode.ts +348 -0
  102. package/src/lib/sign-in-failure.ts +88 -0
  103. package/src/lib/twofa-rejection.ts +7 -2
  104. package/src/middleware/create-middleware.ts +69 -9
  105. package/src/middleware/prefetch-guard-status.ts +56 -0
  106. package/src/pages/enroll/page.tsx +22 -0
  107. package/src/pages/login/page.tsx +2 -1
  108. package/src/routes/auth/session-reset.ts +46 -11
  109. package/src/routes/auth/session.ts +5 -1
  110. package/src/routes/auth/viability.ts +16 -4
  111. package/src/routes/health.ts +14 -0
  112. package/src/routes/mfa/methods.ts +3 -1
  113. package/src/routes/passkeys/list.ts +5 -1
  114. package/src/routes/session/refresh-viability.ts +5 -1
  115. package/src/utils/api.ts +50 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog — @payez/next-mvp
2
2
 
3
+ ## Unreleased
4
+
5
+ - **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).
6
+
7
+ ## 4.13.1 (to be published under dist-tag `next` only, after 4.13.0; `latest` stays 4.8.9 until Jon says otherwise)
8
+
9
+ 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).
10
+
11
+ - **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.
12
+ - **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.
13
+
14
+ ## 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)
15
+
16
+ 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.
17
+
18
+ **Behaviour changes for every consumer (read first)**
19
+
20
+ - **CHANGE (PAY-2218 P2, PR 1752): the session cookies are expired only by a request that is provably a top-level navigation.** Decision table, `isKnownNonNavigationRequest` (new export): `Sec-Fetch-Mode` present and not `navigate`, or `Sec-Fetch-Dest` present and not `document` -> NOT cleared (the redirect is still returned); `navigate`/`document` -> cleared as before; no `Sec-Fetch-*` at all: NOT cleared when `rsc`, `next-router-prefetch`, `next-router-state-tree` or `sec-purpose`/`purpose: prefetch` is present, otherwise cleared as before (curl, old clients, server-to-server). Fixes the router-prefetch sign-out measured on real Next 15.5.9 and 16.2.12 servers with real Chromium. Consequence: a dead cookie is now cleared only by a real navigation to a protected page; an API fetch that gets 401 or a client-side link click does not wipe it, and the login page itself does not clear. One test changed on purpose: an `rsc`-only request no longer clears.
21
+ - **CHANGE (PAY-2467, PR 1612): a session whose IdP answer said a second factor is owed stays gated whatever the tenant config says (fail closed).** The viability override used to fire only on the Better-Auth-record fallback; it now also fires on the canonical session row. An IdP-discarded refresh token is treated as a dead session, not a pending second factor. No code change needed by consumers.
22
+ - **CHANGE (PAY-2568 / PAY-2279, PRs 1719, 1614, 1730): a failed credential sign-in shows a fixed sentence chosen by a stable code, never the server text** (detail in the entries below), including the code step.
23
+
24
+ **Next-version behaviour table (PAY-2218)**
25
+
26
+ | Next | Router-prefetch headers in middleware | Cookie clear after P2 | `PAYEZ_PREFETCH_GUARD_DEAD` log (PR 2) |
27
+ |---|---|---|---|
28
+ | 15.x (measured 15.5.9) | stripped, no opt-out | keys on Sec-Fetch; real-browser gate passes | printed once per process (proven on a real `next start`) |
29
+ | 16.0-16.1 (measured 16.1.5 source) | stripped, no opt-out | same | printed once |
30
+ | 16.2.x (measured 16.2.12) | kept only with `skipProxyUrlNormalize: true`; stripped without it | same (gate passes WITHOUT the flag) | silent by design (the flag cannot be read from user code) |
31
+ | 14 | not measured | not measured | silent |
32
+
33
+ Not covered: Safari/Firefox (Chromium only), non-browser clients and browsers that send no Sec-Fetch headers (they keep the old behaviour).
34
+
35
+ **New, opt-in**
36
+
37
+ - **PAYEZ_CLIENT_SECRET (PAY-1460 3b, PR 1745).** New optional env var, read at call time, never logged. When set it is sent as `client_secret` in the bodies of `sign-client-assertion`, `client-config` and `next-auth/secret`; unset or blank sends exactly the old bodies. Needed before the IdP enforces a client secret for the client (Core PR 1747 / keelsecurity/payez-core #3). A WRONG value is rejected 401 and 5 wrong values in 15 minutes from one source lock that client+source out: verify the secret on a non-prod rig before it goes into a task definition. An app must run this release with the secret set BEFORE its client id leaves `LegacyOpenClientIds`, or it gets the public view.
38
+ - **PAYEZ_PROVIDER_SECRET_MISSING_REDACTED (PR 1745).** New log line, once per provider per process, naming only the provider: the IdP answered `X-PayEz-Secrets-Redacted: true` and no `<PROVIDER>_CLIENT_SECRET` is set; social sign-in for that provider will fail at the code exchange. Set the env var.
39
+ - **PAYEZ_PREFETCH_GUARD_DEAD (PAY-2218 P1, keelauth-next PR 2).** New log line, information only (see the table).
40
+ - **Signed-in authenticator enrolment page (PAY-2631, PR 1726)** for generated apps: new exports `./dist/pages/enroll` and `./components/mfa/TotpEnrollPanel`.
41
+ - **Credential sign-in code step offers enrolment when the IdP says `enroll_mfa` (PAY-2630, PR 1687).** Host-gated, no default `enrollHref`.
42
+ - **Session-reset links can fall back to a cookie-derived key when the edge cannot see the env secret (PAY-2218, PR 1639).** Explicit opt-in, default OFF (`sessionResetCookieKey`, `createSessionResetHandler({ cookieKey: true })`); the strict route logs one value-free warning for a half-set deployment.
43
+
44
+ **Smaller**
45
+
46
+ - **FIX (PAY-2554, PR 1606):** an OAuth callback failure (`/api/auth/error`) redirects with a fixed `OAUTH_SIGN_IN_FAILED` code and the login page shows a sentence.
47
+ - **FIX (PAY-2568, PR 1613):** `createAuthGetHandler` logs the Better Auth error code of an `/api/auth/error` redirect.
48
+ - **FIX (PAY-2279, PR 1614):** login page sentence for `REGISTRATION_NOT_ALLOWED`.
49
+
50
+ **Not in this package**: `packages/cli` changes (PR 1686 stop-before-scaffold on `KEYS_NO_DEVELOPER_TENANT`, PR 1703 non-zero exit on a failed provision, and the CLI half of PR 1726) ship with `@payez/cli`, not `@payez/next-mvp`. Test-only: PR 1751, PR 1555.
51
+
52
+ **Release checks run (NextPert, 2026-11-04)**: package builds from main e0ccb0b9 with `tsc` and no errors; full package suite 1559 tests, 1 failure (`C1`, TypeScript shape rows, fails on main and is unrelated); `exports` map has no NEW dangling target versus 4.12.1 (the dangling targets are PRE-EXISTING, in the published 4.12.1 too, not new in this release; tracked in PAY-2718); `npm pack` (not published) from a clean LF `git archive` of main e0ccb0b9 into a temp dir ran `prepack` (build + `check:publish-eol`) green and produced `payez-next-mvp-4.12.1.tgz` (965 files, 1.3 MB; the version string is still 4.12.1 because nothing was bumped); a throwaway consumer build of VibeID-Customer-Portal 2d0bcf18 (Next 16.2.12, default bundler) against that tarball passed `next build`. At the time of these checks NOT done: `npm ci` from the private feed (my token returns 401), `npm publish`, the postpublish health check, a browser/sign-in walk of the consumer. The consumer build proves the package resolves and compiles, not that sign-in works.
53
+
54
+ ## Earlier entries now rolled into 4.13.0 (previously "Unreleased: PAY-2568 / PAY-2279, draft PR; no release, no pin")
55
+
56
+
57
+ - **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).
58
+ - **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).
59
+
3
60
  ## 4.12.1 (to be published under dist-tag `next` only, after 4.12.0; `latest` stays 4.8.9 until Jon says otherwise)
4
61
 
5
62
  A patch cut from the 4.12.0 tree carrying ONE change: the PAY-2425 hygiene fix below (PR 1500).
@@ -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)();
@@ -41,6 +41,7 @@ exports.POST = POST;
41
41
  * lib/session-store.js").
42
42
  */
43
43
  const server_1 = require("next/server");
44
+ const sign_in_failure_1 = require("../../lib/sign-in-failure");
44
45
  const jose_1 = require("jose");
45
46
  const idp_client_config_1 = require("../../lib/idp-client-config");
46
47
  const client_key_1 = require("../../lib/client-key");
@@ -306,9 +307,13 @@ async function POST(req) {
306
307
  // (MfaController.VerifyTotp answers a 15-minute lockout as 400 {code:"locked"}), which status-only
307
308
  // classification told to "try again".
308
309
  const rejection = (0, twofa_rejection_1.classifyTwofaRejection)(laneResults, idpResponse.headers.get('retry-after'));
310
+ // PAY-2568 follow-up (BAPert 85795): the page takes its sentence from a CODE in the closed table, so this lane must send one.
311
+ // The branch already decided the cause (classifyTwofaRejection); the code follows its status, the IdP's wording is never used.
312
+ const rejectionCode = rejection.status === 429 ? (rejection.rateLimited ? 'second_factor_rate_limited' : 'too_many_attempts') : rejection.status === 502 ? 'second_factor_unavailable' : 'invalid_second_factor';
309
313
  return server_1.NextResponse.json({
310
314
  success: false,
311
- error: rejection.error,
315
+ code: rejectionCode,
316
+ error: (0, sign_in_failure_1.signInFailureMessage)(rejectionCode),
312
317
  ...(rejection.retryAfter !== undefined ? { retryAfter: rejection.retryAfter } : {}),
313
318
  // PAY-2284: the card shows a real "email me a code" action only when this is true.
314
319
  ...(rejection.offerEmailCode ? { offerEmailCode: true } : {}),
@@ -365,15 +370,21 @@ async function POST(req) {
365
370
  }
366
371
  if (idpResponse.status === 429) {
367
372
  const retryAfter = idpResponse.headers.get('retry-after');
368
- return server_1.NextResponse.json({ success: false, error: 'Too many attempts. Please try again later.', retryAfter: retryAfter ? parseInt(retryAfter, 10) : 60 }, { status: 429 });
373
+ return server_1.NextResponse.json({
374
+ success: false,
375
+ code: 'too_many_attempts',
376
+ error: (0, sign_in_failure_1.signInFailureMessage)('too_many_attempts'),
377
+ // BAPert 85807: the page prints this number as minutes, so only a real Retry-After is sent; no invented default.
378
+ ...(retryAfter && Number.isFinite(parseInt(retryAfter, 10)) && parseInt(retryAfter, 10) > 0 ? { retryAfter: parseInt(retryAfter, 10) } : {}),
379
+ }, { status: 429 });
369
380
  }
370
381
  if (!idpResponse.ok) {
371
382
  const f = logIdpAuthRefusal('non-ok', idpResponse.status, data);
372
- return server_1.NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: idpResponse.status });
383
+ return server_1.NextResponse.json(refusalBody(f, idpResponse.status), { status: idpResponse.status });
373
384
  }
374
385
  if (data?.success === false) {
375
386
  const f = logIdpAuthRefusal('envelope-success-false', idpResponse.status, data);
376
- return server_1.NextResponse.json({ success: false, error: publicRefusalMessage(f) }, { status: 200 });
387
+ return server_1.NextResponse.json(refusalBody(f, idpResponse.status), { status: 200 });
377
388
  }
378
389
  // Unwrap the IDP's canonical envelope, same shape login.js normalizes.
379
390
  const result = data?.data?.result ?? data?.result ?? data;
@@ -685,6 +696,19 @@ function publicRefusalMessage(f) {
685
696
  return f.detail;
686
697
  return UNIFORM_REFUSAL;
687
698
  }
699
+ /**
700
+ * The browser-facing body of a refusal (BAPert 85415): a stable machine `code` from the closed table in lib/sign-in-failure, and
701
+ * the fixed sentence for it. The IdP's own message text is never copied in. The one exception is the allowlisted policy refusals
702
+ * (SsoRequired / SsoRequiredBreakGlass / InvalidRequest): a property of the email domain or the request, not of an account, and the
703
+ * user cannot proceed without being told; they carry code 'policy_message' and the allowlisted detail (see SAFE_TO_SHOW).
704
+ */
705
+ function refusalBody(f, status) {
706
+ const policy = f.code && SAFE_TO_SHOW.has(f.code) ? (0, sign_in_failure_1.capPolicyDetail)(f.detail) : null;
707
+ if (policy)
708
+ return { success: false, code: 'policy_message', error: policy };
709
+ const code = (0, sign_in_failure_1.classifySignInFailure)(status, f.code);
710
+ return { success: false, code, error: (0, sign_in_failure_1.signInFailureMessage)(code) };
711
+ }
688
712
  function logIdpAuthRefusal(branch, httpStatus, data) {
689
713
  const f = extractIdpFailure(data);
690
714
  const d = (data ?? {});
@@ -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
+ }
@@ -5,6 +5,7 @@ const server_1 = require("next/server");
5
5
  const idp_fetch_1 = require("../../lib/idp-fetch");
6
6
  const env_1 = require("../../config/env");
7
7
  const safe_error_response_1 = require("../../lib/safe-error-response");
8
+ const mfa_json_guard_1 = require("../../lib/mfa-json-guard");
8
9
  /**
9
10
  * Confirm TOTP enrolment with the first authenticator code
10
11
  * (POST /api/Account/mfa/totp/confirm, user JWT). Body forwarded verbatim —
@@ -12,6 +13,9 @@ const safe_error_response_1 = require("../../lib/safe-error-response");
12
13
  * the method ACTIVE.
13
14
  */
14
15
  async function POST(req) {
16
+ const refused = (0, mfa_json_guard_1.refuseNonJson)(req);
17
+ if (refused)
18
+ return refused;
15
19
  let body = '{}';
16
20
  try {
17
21
  const raw = await req.text();
@@ -30,13 +34,13 @@ async function POST(req) {
30
34
  message: 'Upstream error',
31
35
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: (0, safe_error_response_1.projectIdpError)(result.json) },
32
36
  meta: { attemptedRefresh: result.attemptedRefresh },
33
- }, { status: result.status });
37
+ }, { status: result.status, headers: mfa_json_guard_1.NO_STORE });
34
38
  }
35
39
  const bodyJson = result.json;
36
40
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
37
41
  if (bodyJson.success === true)
38
- return server_1.NextResponse.json(bodyJson.data, { status: 200 });
39
- return server_1.NextResponse.json(bodyJson, { status: 200 });
42
+ return server_1.NextResponse.json(bodyJson.data, { status: 200, headers: mfa_json_guard_1.NO_STORE });
43
+ return server_1.NextResponse.json(bodyJson, { status: 200, headers: mfa_json_guard_1.NO_STORE });
40
44
  }
41
- return server_1.NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return server_1.NextResponse.json(bodyJson ?? {}, { status: 200, headers: mfa_json_guard_1.NO_STORE });
42
46
  }
@@ -5,6 +5,7 @@ const server_1 = require("next/server");
5
5
  const idp_fetch_1 = require("../../lib/idp-fetch");
6
6
  const env_1 = require("../../config/env");
7
7
  const safe_error_response_1 = require("../../lib/safe-error-response");
8
+ const mfa_json_guard_1 = require("../../lib/mfa-json-guard");
8
9
  /**
9
10
  * Begin TOTP enrolment (POST /api/Account/mfa/totp/enroll, user JWT).
10
11
  * Returns the shared secret / otpauth URI for the QR. An enrolment left
@@ -12,6 +13,9 @@ const safe_error_response_1 = require("../../lib/safe-error-response");
12
13
  * as "Active" (MFA honesty fixes, 2026-08-01).
13
14
  */
14
15
  async function POST(req) {
16
+ const refused = (0, mfa_json_guard_1.refuseNonJson)(req);
17
+ if (refused)
18
+ return refused;
15
19
  let body = '{}';
16
20
  try {
17
21
  const raw = await req.text();
@@ -30,13 +34,13 @@ async function POST(req) {
30
34
  message: 'Upstream error',
31
35
  error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: (0, safe_error_response_1.projectIdpError)(result.json) },
32
36
  meta: { attemptedRefresh: result.attemptedRefresh },
33
- }, { status: result.status });
37
+ }, { status: result.status, headers: mfa_json_guard_1.NO_STORE });
34
38
  }
35
39
  const bodyJson = result.json;
36
40
  if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
37
41
  if (bodyJson.success === true)
38
- return server_1.NextResponse.json(bodyJson.data, { status: 200 });
39
- return server_1.NextResponse.json(bodyJson, { status: 200 });
42
+ return server_1.NextResponse.json(bodyJson.data, { status: 200, headers: mfa_json_guard_1.NO_STORE });
43
+ return server_1.NextResponse.json(bodyJson, { status: 200, headers: mfa_json_guard_1.NO_STORE });
40
44
  }
41
- return server_1.NextResponse.json(bodyJson ?? {}, { status: 200 });
45
+ return server_1.NextResponse.json(bodyJson ?? {}, { status: 200, headers: mfa_json_guard_1.NO_STORE });
42
46
  }
@@ -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) {
@@ -65,6 +66,8 @@ async function GET(req) {
65
66
  if (betterAuthSession && sessionToken) {
66
67
  // Try legacy session store first, then Better Auth format
67
68
  let sessionData = await (0, session_store_1.getSession)(sessionToken);
69
+ // PAY-2467: see routes/auth/viability.ts - a boolean mfaVerified on the raw canonical row is a measurement.
70
+ const fromCanonicalStore = !!sessionData;
68
71
  if (!sessionData) {
69
72
  // Better Auth stores sessions with ba:{appSlug}:{token} prefix
70
73
  sessionData = await (0, session_store_1.getBetterAuthSession)(sessionToken);
@@ -113,9 +116,11 @@ async function GET(req) {
113
116
  // only ever SUPPRESS this override, never grant access. Anything that GRANTS must
114
117
  // keep gating on `mfaVerified`, which stays fail-closed at all five sites. R807 is
115
118
  // void if this signal reaches an auth decision.
116
- if (sessionData.mfaSignalPresent === true &&
119
+ if ((sessionData.mfaSignalPresent === true || (fromCanonicalStore && typeof sessionData.mfaVerified === 'boolean')) &&
117
120
  sessionData.mfaVerified === false &&
118
- !sessionData.idpRefreshToken) {
121
+ !sessionData.idpRefreshToken &&
122
+ // same exception as routes/auth/viability: an IdP-discarded refresh token is a dead session, not a pending second factor
123
+ !String(sessionData?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN')) {
119
124
  requires2FA = true;
120
125
  }
121
126
  // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
@@ -218,3 +223,5 @@ async function GET(req) {
218
223
  return server_1.NextResponse.json({ authenticated: false }, { status: 500 });
219
224
  }
220
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
+ }
@@ -623,6 +623,12 @@ export declare const IDP_ERROR_CODES_TO_FORWARD: ReadonlySet<string>;
623
623
  * accepted with Secure) and dropping Expires/Max-Age in favour of Max-Age=0.
624
624
  */
625
625
  export declare function expireSetCookies(setCookies: readonly string[]): string[];
626
+ /**
627
+ * PAY-2568: the Better Auth error code of an /api/auth/error redirect (`?error=<code>`), or null when absent or not a plain
628
+ * token. The value rides a URL, so only a short [A-Za-z0-9_.-] token is ever returned for logging. Location may be relative
629
+ * or absolute; the base is a placeholder used only for parsing.
630
+ */
631
+ export declare function authErrorCodeFromLocation(location: string): string | null;
626
632
  /**
627
633
  * Create a production-ready GET handler for the auth catch-all route.
628
634
  *