@payez/next-mvp 4.4.9 → 4.5.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 (71) hide show
  1. package/README.md +53 -0
  2. package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
  3. package/dist/api-handlers/onboarding/send-email-code.js +11 -0
  4. package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
  5. package/dist/api-handlers/onboarding/set-password.js +15 -0
  6. package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
  7. package/dist/api-handlers/onboarding/verify-code.js +14 -0
  8. package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
  9. package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
  10. package/dist/auth/auth-decision.d.ts +5 -1
  11. package/dist/auth/auth-decision.js +11 -1
  12. package/dist/auth/password-reset-bridge.d.ts +70 -0
  13. package/dist/auth/password-reset-bridge.js +99 -0
  14. package/dist/auth/route-config.js +17 -0
  15. package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
  16. package/dist/components/auth/PasswordResetBridge.js +32 -0
  17. package/dist/edge/middleware.d.ts +1 -1
  18. package/dist/edge/middleware.js +2 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/lib/onboarding-proxy.d.ts +30 -0
  22. package/dist/lib/onboarding-proxy.js +62 -0
  23. package/dist/lib/session-store.d.ts +17 -0
  24. package/dist/lib/session-store.js +33 -0
  25. package/dist/middleware/create-middleware.d.ts +10 -0
  26. package/dist/middleware/create-middleware.js +30 -1
  27. package/dist/pages/login/page.js +1 -1
  28. package/dist/pages/onboarding/index.d.ts +11 -0
  29. package/dist/pages/onboarding/index.js +20 -0
  30. package/dist/pages/onboarding/page.d.ts +1 -0
  31. package/dist/pages/onboarding/page.js +118 -0
  32. package/dist/pages/recovery/page.js +20 -7
  33. package/dist/routes/onboarding/index.d.ts +15 -0
  34. package/dist/routes/onboarding/index.js +22 -0
  35. package/dist/routes/onboarding/send-email-code.d.ts +2 -0
  36. package/dist/routes/onboarding/send-email-code.js +6 -0
  37. package/dist/routes/onboarding/set-password.d.ts +2 -0
  38. package/dist/routes/onboarding/set-password.js +6 -0
  39. package/dist/routes/onboarding/verify-code.d.ts +2 -0
  40. package/dist/routes/onboarding/verify-code.js +6 -0
  41. package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
  42. package/dist/routes/onboarding/verify-welcome-email.js +6 -0
  43. package/dist/server/auth.js +87 -3
  44. package/dist/vibe/sessions.d.ts +10 -22
  45. package/dist/vibe/sessions.js +159 -24
  46. package/package.json +56 -1
  47. package/src/api-handlers/onboarding/send-email-code.ts +18 -0
  48. package/src/api-handlers/onboarding/set-password.ts +21 -0
  49. package/src/api-handlers/onboarding/verify-code.ts +20 -0
  50. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
  51. package/src/auth/auth-decision.ts +14 -1
  52. package/src/auth/password-reset-bridge.ts +98 -0
  53. package/src/auth/route-config.ts +17 -0
  54. package/src/components/auth/PasswordResetBridge.tsx +30 -0
  55. package/src/edge/middleware.ts +1 -1
  56. package/src/index.ts +1 -1
  57. package/src/lib/onboarding-proxy.ts +57 -0
  58. package/src/lib/session-store.ts +42 -0
  59. package/src/middleware/create-middleware.ts +26 -1
  60. package/src/pages/login/page.tsx +4 -7
  61. package/src/pages/onboarding/index.ts +11 -0
  62. package/src/pages/onboarding/page.tsx +212 -0
  63. package/src/pages/recovery/page.tsx +24 -8
  64. package/src/routes/onboarding/index.ts +15 -0
  65. package/src/routes/onboarding/send-email-code.ts +2 -0
  66. package/src/routes/onboarding/set-password.ts +2 -0
  67. package/src/routes/onboarding/verify-code.ts +2 -0
  68. package/src/routes/onboarding/verify-welcome-email.ts +2 -0
  69. package/src/server/auth.ts +90 -3
  70. package/src/vibe/document-unwrap.ts +103 -103
  71. package/src/vibe/sessions.ts +172 -27
package/README.md CHANGED
@@ -256,6 +256,59 @@ export default function VerifyCodePageWrapper() {
256
256
  }
257
257
  ```
258
258
 
259
+ #### What your app must serve (PAY-2121)
260
+
261
+ The IdP emails links that land on paths your app must host. Two of them are
262
+ conventions the kit mounts for you; wire them or the link 404s at your site:
263
+
264
+ **Invitation / onboarding** - `app/account-auth/onboarding/page.tsx`:
265
+
266
+ ```typescript
267
+ // The IdP's welcome email links to
268
+ // {your site}/account-auth/onboarding?client_id=&onboard_token=
269
+ export { default } from '@payez/next-mvp/pages/onboarding';
270
+ ```
271
+
272
+ and the API it posts to (token-driven, no session) - one mount file each:
273
+
274
+ ```typescript
275
+ // app/api/onboarding/verify-code/route.ts
276
+ export { POST } from '@payez/next-mvp/routes/onboarding/verify-code';
277
+ // app/api/onboarding/send-email-code/route.ts
278
+ export { POST } from '@payez/next-mvp/routes/onboarding/send-email-code';
279
+ // app/api/onboarding/verify-welcome-email/route.ts
280
+ export { POST } from '@payez/next-mvp/routes/onboarding/verify-welcome-email';
281
+ // app/api/onboarding/set-password/route.ts
282
+ export { POST } from '@payez/next-mvp/routes/onboarding/set-password';
283
+ ```
284
+
285
+ **Post-verify reset fragment on `/`** - mount the bridge on your home page:
286
+
287
+ ```typescript
288
+ // app/page.tsx
289
+ import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
290
+
291
+ export default function Home() {
292
+ return (<><PasswordResetBridge />{/* your home page */}</>);
293
+ }
294
+ ```
295
+
296
+ When the IdP's verification redirect lands on `/#password_reset_required=true&reset_token=…&email=…`
297
+ (a fragment, for a passwordless account), the bridge performs one replace to
298
+ `/account-auth/recovery?token=…&email=…` and clears the hash. With any other
299
+ fragment it renders nothing. `npx @payez/create-next-app` writes all of the above.
300
+
301
+ **Registration** has two doors, and the kit ships neither as a page:
302
+
303
+ - **Owner-initiated invite** (shipped here): an existing owner invites a user; the invite
304
+ email lands on `/account-auth/onboarding` (above), which the kit now serves.
305
+ - **Self-serve sign-up**: the kit exposes the IdP's ProgressiveAuth flow as API endpoints
306
+ (`/api/ProgressiveAuth/step1`..`step5`, then `/api/ProgressiveAuth/verify-email`;
307
+ `API_ENDPOINTS.progressiveAuth` in `config/env.ts`). The kit ships **no** registration
308
+ page, so a developer who wants self-serve sign-up authors a page that drives that flow,
309
+ respecting the tenant's `allow_public_registration` / waitlist state. The login page no
310
+ longer links a register page, because the kit does not ship one.
311
+
259
312
  ### 6. Protect Routes with Authentication
260
313
 
261
314
  **Server-side protection:**
@@ -0,0 +1,11 @@
1
+ /**
2
+ * POST /api/onboarding/send-email-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/send-email-code. The IdP derives the recipient from the
6
+ * token's BOUND email only, never from client input, so a tampered link cannot
7
+ * redirect the code. Also the resend leg the verify card's cooldown fires
8
+ * against.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const onboardToken = body?.onboard_token;
8
+ if (!onboardToken)
9
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
10
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/send-email-code');
11
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * POST /api/onboarding/set-password (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, email, reset_token, new_password } - the locked
5
+ * contract (IdP OnboardingDtos.cs SetPasswordRequest, all four snake_case).
6
+ * email + reset_token come from the verify-code response; the IdP re-checks
7
+ * email against the token's bound user before consuming the single-use token.
8
+ * On success the IdP sets the password AND completes onboarding (issues tokens).
9
+ * Thin proxy - status and body pass through verbatim.
10
+ */
11
+ import { NextRequest } from 'next/server';
12
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const b = body;
8
+ if (!b?.onboard_token)
9
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
10
+ if (!b?.email || !b?.reset_token)
11
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_RESET_CONTEXT', 'email and reset_token (from verify-code) are required');
12
+ if (!b?.new_password)
13
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_PASSWORD', 'new_password is required');
14
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/set-password');
15
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * POST /api/onboarding/verify-code (PAY-2121).
3
+ *
4
+ * Body: { onboard_token, code } - token-driven, no session. Thin proxy to the
5
+ * IdP's POST /api/Onboarding/verify-code, which answers
6
+ * { success, reset_token, email, client_key } on success (OnboardingDtos.cs
7
+ * VerifyCodeResponse). reset_token + email are what the set-password step needs
8
+ * next. The onboard_token IS the auth; there is no session to attach.
9
+ */
10
+ import { NextRequest } from 'next/server';
11
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const onboardToken = body?.onboard_token;
8
+ const code = body?.code;
9
+ if (!onboardToken)
10
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
11
+ if (!code)
12
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_CODE', 'code is required');
13
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-code');
14
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * POST /api/onboarding/verify-welcome-email (PAY-2121).
3
+ *
4
+ * Body: { onboard_token } - token-driven, no session. Thin proxy to the IdP's
5
+ * POST /api/Onboarding/verify-welcome-email, which answers the masked contact
6
+ * info + expiry the verify card displays. The onboarding page calls this to
7
+ * render its "check your email / here is what we know" state.
8
+ */
9
+ import { NextRequest } from 'next/server';
10
+ export declare function POST(req: NextRequest): Promise<import("next/server").NextResponse<unknown>>;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POST = POST;
4
+ const onboarding_proxy_1 = require("../../lib/onboarding-proxy");
5
+ async function POST(req) {
6
+ const body = await req.json().catch(() => ({}));
7
+ const onboardToken = body?.onboard_token;
8
+ if (!onboardToken)
9
+ return (0, onboarding_proxy_1.onboardingBadRequest)('MISSING_TOKEN', 'onboard_token is required');
10
+ return (0, onboarding_proxy_1.onboardingProxy)(req, '/api/Onboarding/verify-welcome-email');
11
+ }
@@ -21,6 +21,8 @@ export interface AuthContext {
21
21
  twoFactorComplete: boolean;
22
22
  };
23
23
  circuitBreakerOpen: boolean;
24
+ /** PAY-2136: the request is a router prefetch. A prefetch never clears cookies (see isPrefetchRequest). */
25
+ isPrefetch?: boolean;
24
26
  }
25
27
  export type AuthAction = {
26
28
  type: 'allow';
@@ -34,6 +36,8 @@ export type AuthAction = {
34
36
  reason: string;
35
37
  };
36
38
  /**
37
- * Main decision function - pure, testable, deterministic
39
+ * Main decision function - pure, testable, deterministic.
40
+ * PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
41
+ * redirect itself is kept, and the next real navigation reaches the same decision and clears then.
38
42
  */
39
43
  export declare function makeAuthDecision(context: AuthContext): AuthAction;
@@ -10,9 +10,19 @@ exports.makeAuthDecision = makeAuthDecision;
10
10
  const safe_callback_1 = require("./safe-callback");
11
11
  const token_log_id_1 = require("../lib/token-log-id");
12
12
  /**
13
- * Main decision function - pure, testable, deterministic
13
+ * Main decision function - pure, testable, deterministic.
14
+ * PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
15
+ * redirect itself is kept, and the next real navigation reaches the same decision and clears then.
14
16
  */
15
17
  function makeAuthDecision(context) {
18
+ const action = decide(context);
19
+ if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
20
+ const { clearCookies: _dropped, ...rest } = action;
21
+ return rest;
22
+ }
23
+ return action;
24
+ }
25
+ function decide(context) {
16
26
  const { pathname, isPublicRoute, isLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = context;
17
27
  // SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops.
18
28
  // 375656: `pathname` is our own request path rather than an attacker-supplied
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PAY-2121: the fragment bridge on `/`.
3
+ *
4
+ * The IdP's post-verify redirect for a passwordless account lands on
5
+ * ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
6
+ * - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
7
+ * @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
8
+ * and nothing happens; their reset is lost.
9
+ *
10
+ * This is the PURE half (data in, data out) so a test can execute it without a
11
+ * DOM. The client component (PasswordResetBridge) calls it on mount with
12
+ * `window.location.hash`. The kit's reset surface is the existing
13
+ * `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
14
+ * bridge rewrites the fragment into that surface's OWN query shape and navigates
15
+ * once. It invents no new reset flow.
16
+ *
17
+ * CONTRACT:
18
+ * '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
19
+ * any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
20
+ */
21
+ /** The reset surface the bridge hands off to, by its existing path. */
22
+ export declare const RECOVERY_PATH = "/account-auth/recovery";
23
+ /**
24
+ * PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
25
+ *
26
+ * The bridge now sends a VERIFIED passwordless user to
27
+ * /account-auth/recovery?token=<reset_token>&email=<addr>
28
+ * with a REAL reset token in hand. If the page then showed its request-a-code
29
+ * step, it would throw that token away and the flow would still dead-end - just
30
+ * one page later. So when a token is present the page must start at the
31
+ * set-password step and submit straight through resetPasswordWithToken.
32
+ *
33
+ * token present -> { step: 'set-password', resetToken: <token> }
34
+ * no token -> { step: 'initiate', resetToken: null } (today's behaviour)
35
+ */
36
+ export declare function recoveryEntry(token: string | null | undefined, email: string | null | undefined): {
37
+ step: 'set-password' | 'initiate';
38
+ resetToken: string | null;
39
+ email: string;
40
+ };
41
+ /**
42
+ * PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
43
+ * function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
44
+ * Removing the session half (so a deep-linked reset can submit) must NOT let a
45
+ * submit with NO token reach the API carrying an empty token - which is exactly
46
+ * what a guard-less submit would do. This decides it, in one testable place:
47
+ *
48
+ * resetSubmitTarget(resetToken, sessionEmail, queryEmail):
49
+ * - no token (after trim) -> null (REFUSE: never call the API with an empty token)
50
+ * - token, session email present -> { token, email: sessionEmail }
51
+ * - token, only a query email -> { token, email: queryEmail } (the deep-link case)
52
+ * - token, but NO email anywhere -> null (REFUSE: nothing to reset)
53
+ *
54
+ * Returns the exact { token, email } the caller must pass to
55
+ * accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
56
+ */
57
+ export declare function resetSubmitTarget(resetToken: string | null | undefined, sessionEmail: string | null | undefined, queryEmail: string | null | undefined): {
58
+ token: string;
59
+ email: string;
60
+ } | null;
61
+ /**
62
+ * The redirect the fragment asks for, or null when the fragment is not a reset
63
+ * request. `hash` is `window.location.hash` (leading '#' optional).
64
+ *
65
+ * Returns null - leaves `/` untouched - when:
66
+ * - the fragment is empty;
67
+ * - `password_reset_required` is absent or not exactly 'true';
68
+ * - no `reset_token` is present (nothing to reset with; not a real link).
69
+ */
70
+ export declare function passwordResetRedirect(hash: string | null | undefined): string | null;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /**
3
+ * PAY-2121: the fragment bridge on `/`.
4
+ *
5
+ * The IdP's post-verify redirect for a passwordless account lands on
6
+ * ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
7
+ * - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
8
+ * @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
9
+ * and nothing happens; their reset is lost.
10
+ *
11
+ * This is the PURE half (data in, data out) so a test can execute it without a
12
+ * DOM. The client component (PasswordResetBridge) calls it on mount with
13
+ * `window.location.hash`. The kit's reset surface is the existing
14
+ * `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
15
+ * bridge rewrites the fragment into that surface's OWN query shape and navigates
16
+ * once. It invents no new reset flow.
17
+ *
18
+ * CONTRACT:
19
+ * '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
20
+ * any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.RECOVERY_PATH = void 0;
24
+ exports.recoveryEntry = recoveryEntry;
25
+ exports.resetSubmitTarget = resetSubmitTarget;
26
+ exports.passwordResetRedirect = passwordResetRedirect;
27
+ /** The reset surface the bridge hands off to, by its existing path. */
28
+ exports.RECOVERY_PATH = '/account-auth/recovery';
29
+ /**
30
+ * PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
31
+ *
32
+ * The bridge now sends a VERIFIED passwordless user to
33
+ * /account-auth/recovery?token=<reset_token>&email=<addr>
34
+ * with a REAL reset token in hand. If the page then showed its request-a-code
35
+ * step, it would throw that token away and the flow would still dead-end - just
36
+ * one page later. So when a token is present the page must start at the
37
+ * set-password step and submit straight through resetPasswordWithToken.
38
+ *
39
+ * token present -> { step: 'set-password', resetToken: <token> }
40
+ * no token -> { step: 'initiate', resetToken: null } (today's behaviour)
41
+ */
42
+ function recoveryEntry(token, email) {
43
+ const tok = (token ?? '').trim();
44
+ return {
45
+ step: tok ? 'set-password' : 'initiate',
46
+ resetToken: tok || null,
47
+ email: (email ?? '').trim(),
48
+ };
49
+ }
50
+ /**
51
+ * PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
52
+ * function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
53
+ * Removing the session half (so a deep-linked reset can submit) must NOT let a
54
+ * submit with NO token reach the API carrying an empty token - which is exactly
55
+ * what a guard-less submit would do. This decides it, in one testable place:
56
+ *
57
+ * resetSubmitTarget(resetToken, sessionEmail, queryEmail):
58
+ * - no token (after trim) -> null (REFUSE: never call the API with an empty token)
59
+ * - token, session email present -> { token, email: sessionEmail }
60
+ * - token, only a query email -> { token, email: queryEmail } (the deep-link case)
61
+ * - token, but NO email anywhere -> null (REFUSE: nothing to reset)
62
+ *
63
+ * Returns the exact { token, email } the caller must pass to
64
+ * accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
65
+ */
66
+ function resetSubmitTarget(resetToken, sessionEmail, queryEmail) {
67
+ const tok = (resetToken ?? '').trim();
68
+ if (!tok)
69
+ return null;
70
+ const email = ((sessionEmail ?? '').trim() || (queryEmail ?? '').trim());
71
+ if (!email)
72
+ return null;
73
+ return { token: tok, email };
74
+ }
75
+ /**
76
+ * The redirect the fragment asks for, or null when the fragment is not a reset
77
+ * request. `hash` is `window.location.hash` (leading '#' optional).
78
+ *
79
+ * Returns null - leaves `/` untouched - when:
80
+ * - the fragment is empty;
81
+ * - `password_reset_required` is absent or not exactly 'true';
82
+ * - no `reset_token` is present (nothing to reset with; not a real link).
83
+ */
84
+ function passwordResetRedirect(hash) {
85
+ const raw = (hash ?? '').replace(/^#/, '');
86
+ if (!raw)
87
+ return null;
88
+ const params = new URLSearchParams(raw);
89
+ if (params.get('password_reset_required') !== 'true')
90
+ return null;
91
+ const token = params.get('reset_token') ?? '';
92
+ if (!token)
93
+ return null;
94
+ const email = params.get('email') ?? '';
95
+ const qs = new URLSearchParams({ token });
96
+ if (email)
97
+ qs.set('email', email);
98
+ return `${exports.RECOVERY_PATH}?${qs.toString()}`;
99
+ }
@@ -46,6 +46,23 @@ const defaultUnauthenticatedRoutes = [
46
46
  allowDuringCircuitBreakerOpen: true,
47
47
  requiresRateLimit: false
48
48
  },
49
+ // PAY-2121: the IdP emails an invite link to /account-auth/onboarding and a
50
+ // post-verify reset FRAGMENT on '/'. Both must be reachable unauthenticated -
51
+ // the invitee has no session (the onboard_token IS the credential), and the
52
+ // fragment lands on the already-public root. Listed explicitly, not left to
53
+ // the /account-auth/* wildcard, so the two conventional paths are visible here.
54
+ {
55
+ pattern: '/account-auth/onboarding',
56
+ description: 'IdP invite/onboarding landing page (token-driven, no session)',
57
+ allowDuringCircuitBreakerOpen: true,
58
+ requiresRateLimit: false
59
+ },
60
+ {
61
+ pattern: '/api/onboarding/*',
62
+ description: 'Onboarding API (token-driven, no session)',
63
+ allowDuringCircuitBreakerOpen: true,
64
+ requiresRateLimit: true
65
+ },
49
66
  {
50
67
  pattern: '/api/auth/*',
51
68
  description: 'Authentication API endpoints',
@@ -0,0 +1,17 @@
1
+ /**
2
+ * PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
3
+ * redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
4
+ * (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
5
+ * the hash. With any other fragment it renders nothing and leaves `/` alone.
6
+ *
7
+ * It renders nothing and reads no user data - it only inspects the fragment.
8
+ *
9
+ * @example
10
+ * // app/page.tsx
11
+ * import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
12
+ * export default function Home() {
13
+ * return (&lt;&gt;&lt;PasswordResetBridge /&gt;&lt;/&gt;);
14
+ * }
15
+ */
16
+ export declare function PasswordResetBridge(): null;
17
+ export default PasswordResetBridge;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.PasswordResetBridge = PasswordResetBridge;
5
+ const react_1 = require("react");
6
+ const password_reset_bridge_1 = require("../../auth/password-reset-bridge");
7
+ /**
8
+ * PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
9
+ * redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
10
+ * (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
11
+ * the hash. With any other fragment it renders nothing and leaves `/` alone.
12
+ *
13
+ * It renders nothing and reads no user data - it only inspects the fragment.
14
+ *
15
+ * @example
16
+ * // app/page.tsx
17
+ * import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
18
+ * export default function Home() {
19
+ * return (&lt;&gt;&lt;PasswordResetBridge /&gt;&lt;/&gt;);
20
+ * }
21
+ */
22
+ function PasswordResetBridge() {
23
+ (0, react_1.useEffect)(() => {
24
+ if (typeof window === 'undefined')
25
+ return;
26
+ const target = (0, password_reset_bridge_1.passwordResetRedirect)(window.location.hash);
27
+ if (target)
28
+ window.location.replace(target);
29
+ }, []);
30
+ return null;
31
+ }
32
+ exports.default = PasswordResetBridge;
@@ -7,7 +7,7 @@
7
7
  export { makeAuthDecision } from '../auth/auth-decision';
8
8
  export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig, configure2FABypassRoutes, should2FABypass, get2FABypassConfig } from '../auth/route-config';
9
9
  export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
10
- export { createMvpMiddleware } from '../middleware/create-middleware';
10
+ export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
11
11
  export type { MvpMiddlewareOptions, CircuitBreakerProvider, MiddlewareLogger, AuthContext, AuthAction, SessionPointer, SessionStatus } from '../middleware/create-middleware';
12
12
  export { TwoFactorPresets, AMRValues, ACRLevels, validateAMR, validateACR, checkTwoFactorRequirements } from '../middleware/twofa-presets';
13
13
  export type { TwoFactorRequirements, RouteConfig } from '../middleware/twofa-presets';
@@ -6,7 +6,7 @@
6
6
  * Client-side utilities that depend on browser APIs or server-only code are excluded.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.getInternalApiUrl = exports.checkTwoFactorRequirements = exports.validateACR = exports.validateAMR = exports.ACRLevels = exports.AMRValues = exports.TwoFactorPresets = exports.createMvpMiddleware = exports.get2FABypassConfig = exports.should2FABypass = exports.configure2FABypassRoutes = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = void 0;
9
+ exports.getInternalApiUrl = exports.checkTwoFactorRequirements = exports.validateACR = exports.validateAMR = exports.ACRLevels = exports.AMRValues = exports.TwoFactorPresets = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.get2FABypassConfig = exports.should2FABypass = exports.configure2FABypassRoutes = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = void 0;
10
10
  // Authentication middleware and configuration (Edge Runtime compatible)
11
11
  var auth_decision_1 = require("../auth/auth-decision");
12
12
  Object.defineProperty(exports, "makeAuthDecision", { enumerable: true, get: function () { return auth_decision_1.makeAuthDecision; } });
@@ -19,6 +19,7 @@ Object.defineProperty(exports, "should2FABypass", { enumerable: true, get: funct
19
19
  Object.defineProperty(exports, "get2FABypassConfig", { enumerable: true, get: function () { return route_config_1.get2FABypassConfig; } });
20
20
  var create_middleware_1 = require("../middleware/create-middleware");
21
21
  Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
22
+ Object.defineProperty(exports, "isPrefetchRequest", { enumerable: true, get: function () { return create_middleware_1.isPrefetchRequest; } });
22
23
  // Two-Factor Authentication Presets (Edge Runtime compatible)
23
24
  var twofa_presets_1 = require("../middleware/twofa-presets");
24
25
  Object.defineProperty(exports, "TwoFactorPresets", { enumerable: true, get: function () { return twofa_presets_1.TwoFactorPresets; } });
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { AuthConfig } from './types/auth';
6
6
  export { makeAuthDecision } from './auth/auth-decision';
7
7
  export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from './auth/route-config';
8
8
  export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
9
- export { createMvpMiddleware } from './middleware/create-middleware';
9
+ export { createMvpMiddleware, isPrefetchRequest } from './middleware/create-middleware';
10
10
  export { UserAvatarMenu, MobileNavDrawer } from './components/account';
11
11
  export type { UserAvatarMenuProps, MobileNavDrawerProps, NavItem, NavSection } from './components/account';
12
12
  export { ErrorMetricsCard, HealthMetricsCard, AuditLogViewer, AdminAnalyticsLayout, useErrorMetrics, useHealthMetrics, useAuditLog, useAdminAnalytics, getErrorMetrics, getHealthMetrics, writeAuditLog, queryAuditLog, } from './logging';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // - Better Auth: import { createBetterAuthInstance } from '@payez/next-mvp/auth/better-auth'
9
9
  // - Server auth: import { getSession } from '@payez/next-mvp/server/auth'
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
11
+ exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
12
12
  // Client-safe exports only
13
13
  // Client-side utilities
14
14
  var fetch_with_auth_1 = require("./client/fetch-with-auth");
@@ -40,6 +40,7 @@ Object.defineProperty(exports, "resolveCallbackDestination", { enumerable: true,
40
40
  Object.defineProperty(exports, "DEFAULT_CALLBACK", { enumerable: true, get: function () { return safe_callback_1.DEFAULT_CALLBACK; } });
41
41
  var create_middleware_1 = require("./middleware/create-middleware");
42
42
  Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
43
+ Object.defineProperty(exports, "isPrefetchRequest", { enumerable: true, get: function () { return create_middleware_1.isPrefetchRequest; } });
43
44
  // Account Components
44
45
  var account_1 = require("./components/account");
45
46
  Object.defineProperty(exports, "UserAvatarMenu", { enumerable: true, get: function () { return account_1.UserAvatarMenu; } });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * PAY-2121: shared anonymous IDP proxy for the /api/onboarding/* handlers.
3
+ *
4
+ * The onboarding flow is TOKEN-DRIVEN: the invitee has no account or session
5
+ * yet, and the onboard_token IS the credential. So these calls must NOT go
6
+ * through idpFetchJSON (which injects a Bearer from a Redis session) - there is
7
+ * no session. They are plain server-to-server POSTs to the IdP, exactly as the
8
+ * VibeID portal's hand-built /api/onboarding/* routes do (card 182611).
9
+ *
10
+ * WHY A SERVER ROUTE AT ALL, AND NOT A BROWSER CALL: the IdP URL is not exposed
11
+ * to the browser, and the route is the one place that owns that. The token is
12
+ * forwarded, never the IdP's host.
13
+ */
14
+ import { NextRequest, NextResponse } from 'next/server';
15
+ /** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
16
+ export declare function onboardingBadRequest(code: string, message: string): NextResponse<{
17
+ success: boolean;
18
+ message: string;
19
+ error: {
20
+ code: string;
21
+ };
22
+ }>;
23
+ /**
24
+ * POST a JSON body to `{IDP_URL}{idpPath}` with no credentials, and relay the
25
+ * IdP's status and body verbatim (the page acts on what the IdP actually
26
+ * returns, not on a shape this layer guesses). Never relays the IdP error TEXT
27
+ * for a non-2xx is NOT done here: the onboarding endpoints answer structured
28
+ * envelopes the page reads; the page owns the copy.
29
+ */
30
+ export declare function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse>;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.onboardingBadRequest = onboardingBadRequest;
4
+ exports.onboardingProxy = onboardingProxy;
5
+ /**
6
+ * PAY-2121: shared anonymous IDP proxy for the /api/onboarding/* handlers.
7
+ *
8
+ * The onboarding flow is TOKEN-DRIVEN: the invitee has no account or session
9
+ * yet, and the onboard_token IS the credential. So these calls must NOT go
10
+ * through idpFetchJSON (which injects a Bearer from a Redis session) - there is
11
+ * no session. They are plain server-to-server POSTs to the IdP, exactly as the
12
+ * VibeID portal's hand-built /api/onboarding/* routes do (card 182611).
13
+ *
14
+ * WHY A SERVER ROUTE AT ALL, AND NOT A BROWSER CALL: the IdP URL is not exposed
15
+ * to the browser, and the route is the one place that owns that. The token is
16
+ * forwarded, never the IdP's host.
17
+ */
18
+ const server_1 = require("next/server");
19
+ const env_1 = require("../config/env");
20
+ const idp_url_guard_1 = require("./idp-url-guard");
21
+ /** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
22
+ function onboardingBadRequest(code, message) {
23
+ return server_1.NextResponse.json({ success: false, message, error: { code } }, { status: 400 });
24
+ }
25
+ /**
26
+ * POST a JSON body to `{IDP_URL}{idpPath}` with no credentials, and relay the
27
+ * IdP's status and body verbatim (the page acts on what the IdP actually
28
+ * returns, not on a shape this layer guesses). Never relays the IdP error TEXT
29
+ * for a non-2xx is NOT done here: the onboarding endpoints answer structured
30
+ * envelopes the page reads; the page owns the copy.
31
+ */
32
+ async function onboardingProxy(req, idpPath) {
33
+ const idpBase = env_1.ENV_CONFIG.IDP_URL;
34
+ if (!idpBase) {
35
+ return server_1.NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
36
+ }
37
+ const url = `${idpBase}${idpPath}`;
38
+ (0, idp_url_guard_1.assertAbsoluteIdpUrl)(url);
39
+ let bodyText = '{}';
40
+ try {
41
+ const raw = await req.text();
42
+ if (raw && raw.trim().length > 0)
43
+ bodyText = raw;
44
+ }
45
+ catch { /* empty body is fine */ }
46
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
47
+ const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
48
+ if (xfwd)
49
+ headers['X-Forwarded-For'] = xfwd;
50
+ const ua = req.headers.get('user-agent');
51
+ if (ua)
52
+ headers['User-Agent'] = ua;
53
+ let res;
54
+ try {
55
+ res = await fetch(url, { method: 'POST', headers, body: bodyText, cache: 'no-store' });
56
+ }
57
+ catch {
58
+ return server_1.NextResponse.json({ success: false, message: 'The identity service is unreachable.', error: { code: 'UPSTREAM_UNREACHABLE' } }, { status: 502 });
59
+ }
60
+ const json = await res.json().catch(() => null);
61
+ return server_1.NextResponse.json(json ?? { success: res.ok }, { status: res.status });
62
+ }
@@ -218,3 +218,20 @@ export declare function isRefreshInProgress(sessionToken: string): Promise<boole
218
218
  * Force cleanup of expired or orphaned refresh locks
219
219
  */
220
220
  export declare function cleanupRefreshLocks(): Promise<number>;
221
+ /**
222
+ * PAY-2136 DIAGNOSTIC (BAPert 70873): which of the session keys EXIST for a token, right now.
223
+ *
224
+ * Names/presence only - this returns booleans and the key PREFIX (an app slug, not a secret), never a value and
225
+ * never any part of the token. It exists so a NO_SESSION can be read for what it was: canonical row gone with the
226
+ * ba: record alive (the :330 asymmetry), both gone (expired/deleted), or the store could not be asked ('error').
227
+ *
228
+ * EXISTS, not GET: it cannot parse, cannot throw on a malformed blob, and cannot return a credential. Each key is
229
+ * asked separately so one timeout does not hide the other two. Never throws.
230
+ */
231
+ export type KeyPresence = boolean | 'error';
232
+ export declare function describeSessionKeyPresence(sessionToken: string): Promise<{
233
+ prefix: string;
234
+ canonical: KeyPresence;
235
+ ba: KeyPresence;
236
+ ver: KeyPresence;
237
+ }>;