@payez/next-mvp 4.1.21 → 4.4.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 (178) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/client/better-auth-client.d.ts +62 -62
  31. package/dist/components/account/UserAvatarMenu.js +12 -0
  32. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  33. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  34. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  35. package/dist/hooks/useAuthSettings.js +14 -2
  36. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  37. package/dist/hooks/usePublicAuthSettings.js +28 -14
  38. package/dist/lib/client-key.d.ts +61 -0
  39. package/dist/lib/client-key.js +72 -0
  40. package/dist/lib/demo-mode.js +2 -1
  41. package/dist/lib/ensure-fresh-access-token.js +5 -1
  42. package/dist/lib/idp-client-config.d.ts +25 -2
  43. package/dist/lib/idp-client-config.js +381 -36
  44. package/dist/lib/idp-fetch.d.ts +2 -0
  45. package/dist/lib/idp-fetch.js +31 -1
  46. package/dist/lib/idp-url-guard.d.ts +29 -0
  47. package/dist/lib/idp-url-guard.js +38 -0
  48. package/dist/lib/redis.js +77 -5
  49. package/dist/lib/session-cookie-shape.d.ts +69 -0
  50. package/dist/lib/session-cookie-shape.js +126 -0
  51. package/dist/lib/session-store.d.ts +35 -2
  52. package/dist/lib/session-store.js +135 -4
  53. package/dist/lib/single-flight.d.ts +35 -0
  54. package/dist/lib/single-flight.js +30 -0
  55. package/dist/lib/token-lifecycle.js +12 -0
  56. package/dist/middleware/create-middleware.js +12 -0
  57. package/dist/models/SessionModel.d.ts +41 -0
  58. package/dist/models/SessionModel.js +9 -0
  59. package/dist/pages/admin-login/page.d.ts +1 -1
  60. package/dist/pages/admin-login/page.js +5 -41
  61. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  62. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  63. package/dist/pages/security/index.d.ts +1 -0
  64. package/dist/pages/security/index.js +3 -0
  65. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  66. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  67. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  68. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  69. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  70. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  71. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  72. package/dist/pages/test-env/TestEnvPage.js +16 -4
  73. package/dist/pages/test-env/guarded.d.ts +4 -0
  74. package/dist/pages/test-env/guarded.js +86 -0
  75. package/dist/pages/test-env/index.d.ts +50 -7
  76. package/dist/pages/test-env/index.js +58 -12
  77. package/dist/routes/auth/logout.js +3 -2
  78. package/dist/routes/auth/refresh.js +27 -1
  79. package/dist/routes/auth/session.d.ts +12 -3
  80. package/dist/routes/auth/session.js +92 -9
  81. package/dist/routes/auth/settings.d.ts +34 -4
  82. package/dist/routes/auth/settings.js +87 -33
  83. package/dist/routes/auth/viability.d.ts +1 -1
  84. package/dist/routes/auth/viability.js +122 -11
  85. package/dist/server/auth-guard.js +22 -15
  86. package/dist/server/auth.d.ts +177 -20
  87. package/dist/server/auth.js +34 -75
  88. package/dist/server/decode-session.js +84 -10
  89. package/dist/types/auth.d.ts +18 -0
  90. package/package.json +969 -958
  91. package/scripts/check-internal-url-usage.sh +73 -73
  92. package/scripts/check-publish-health.mjs +162 -0
  93. package/scripts/dev-broker.ps1 +71 -6
  94. package/scripts/print-client-config.mjs +162 -0
  95. package/scripts/server-only-stub.cjs +4 -0
  96. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  97. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  98. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  99. package/src/api-handlers/account/reset-password.ts +35 -2
  100. package/src/api-handlers/account/validate-password.ts +22 -3
  101. package/src/api-handlers/admin/analytics.ts +2 -1
  102. package/src/api-handlers/admin/audit.ts +2 -1
  103. package/src/api-handlers/admin/sessions.ts +2 -1
  104. package/src/api-handlers/admin/stats.ts +2 -1
  105. package/src/api-handlers/admin/users.ts +2 -1
  106. package/src/api-handlers/admin/vibe-data.ts +2 -1
  107. package/src/api-handlers/auth/jwks.ts +10 -0
  108. package/src/api-handlers/auth/login.ts +5 -2
  109. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  110. package/src/api-handlers/auth/refresh.ts +112 -15
  111. package/src/api-handlers/auth/signout.ts +4 -3
  112. package/src/api-handlers/auth/verify-code.ts +5 -2
  113. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  114. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  115. package/src/api-handlers/mfa/methods.ts +46 -46
  116. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  117. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  118. package/src/api-handlers/passkeys/list.ts +31 -31
  119. package/src/api-handlers/passkeys/manage.ts +54 -54
  120. package/src/api-handlers/passkeys/register-options.ts +42 -42
  121. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  122. package/src/api-handlers/session/viability.ts +29 -1
  123. package/src/api-handlers/test/force-expire.ts +118 -13
  124. package/src/auth/better-auth.ts +486 -51
  125. package/src/auth/utils/idp-client.ts +1 -1
  126. package/src/client/AuthContext.tsx +55 -19
  127. package/src/components/account/UserAvatarMenu.tsx +11 -0
  128. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  129. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  130. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  131. package/src/hooks/useAuthSettings.ts +14 -2
  132. package/src/hooks/usePublicAuthSettings.ts +38 -16
  133. package/src/lib/client-key.ts +88 -0
  134. package/src/lib/demo-mode.ts +2 -1
  135. package/src/lib/ensure-fresh-access-token.ts +5 -1
  136. package/src/lib/idp-client-config.ts +399 -36
  137. package/src/lib/idp-fetch.ts +36 -1
  138. package/src/lib/idp-url-guard.ts +37 -0
  139. package/src/lib/redis.ts +84 -6
  140. package/src/lib/session-cookie-shape.ts +184 -0
  141. package/src/lib/session-store.ts +152 -6
  142. package/src/lib/single-flight.ts +64 -0
  143. package/src/lib/token-lifecycle.ts +12 -0
  144. package/src/middleware/create-middleware.ts +11 -0
  145. package/src/models/SessionModel.ts +49 -0
  146. package/src/pages/admin-login/page.tsx +17 -127
  147. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  148. package/src/pages/security/index.ts +3 -0
  149. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  150. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  151. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  152. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  153. package/src/pages/test-env/guarded.tsx +81 -0
  154. package/src/pages/test-env/index.ts +59 -7
  155. package/src/routes/auth/logout.ts +4 -3
  156. package/src/routes/auth/passkey-signin.ts +13 -13
  157. package/src/routes/auth/refresh.ts +30 -1
  158. package/src/routes/auth/session.ts +98 -9
  159. package/src/routes/auth/settings.ts +144 -42
  160. package/src/routes/auth/viability.ts +129 -11
  161. package/src/routes/mfa/backup-code-verify.ts +1 -1
  162. package/src/routes/mfa/backup-codes.ts +10 -10
  163. package/src/routes/mfa/methods.ts +22 -22
  164. package/src/routes/mfa/totp-confirm.ts +10 -10
  165. package/src/routes/mfa/totp-enroll.ts +10 -10
  166. package/src/routes/mfa/totp-verify.ts +14 -14
  167. package/src/routes/passkeys/list.ts +10 -10
  168. package/src/routes/passkeys/manage.ts +23 -23
  169. package/src/routes/passkeys/register-options.ts +10 -10
  170. package/src/routes/passkeys/register-verify.ts +10 -10
  171. package/src/server/auth-guard.ts +24 -17
  172. package/src/server/auth.ts +37 -73
  173. package/src/server/decode-session.ts +95 -11
  174. package/src/templates/instrumentation.ts +21 -2
  175. package/src/types/auth.ts +20 -0
  176. package/dist/lib/auth-secret.d.ts +0 -17
  177. package/dist/lib/auth-secret.js +0 -116
  178. package/src/lib/auth-secret.ts +0 -137
@@ -1,73 +1,73 @@
1
- #!/bin/bash
2
- #
3
- # CI Check: Detect incorrect internal API URL patterns
4
- #
5
- # This script fails if code is using req.url or req.nextUrl.origin to construct
6
- # URLs for internal API calls. These patterns cause 403 errors in Kubernetes
7
- # because they use the external HTTPS URL instead of INTERNAL_API_URL.
8
- #
9
- # CORRECT: Use internalFetch() or internalRefresh() from @payez/next-mvp/lib/internal-api
10
- # INCORRECT: new URL('/api/...', req.url) or fetch(`${req.nextUrl.origin}/api/...`)
11
- #
12
- # NOTE: new URL(req.url) alone is FINE - it just parses the incoming request URL.
13
- # The problem is when you construct a NEW path using req.url as the base.
14
- #
15
-
16
- set -e
17
-
18
- SEARCH_PATH="${1:-.}"
19
- ERRORS_FOUND=0
20
-
21
- echo "Checking for incorrect internal URL patterns in: $SEARCH_PATH"
22
- echo "========================================================================"
23
-
24
- # Pattern 1: new URL('/api/...', req.url) - constructing internal API URL from request
25
- # This catches: new URL('/api/...', req.url) and new URL("/api/...", req.url)
26
- # But NOT: new URL(req.url) which is just parsing the request
27
- echo ""
28
- echo "Checking for: new URL('/api/...', req.url) pattern"
29
- if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\(['\"][^'\"]*['\"],\s*req\.url" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
30
- echo "ERROR: Found 'new URL(path, req.url)' pattern. Use internalFetch() instead."
31
- ERRORS_FOUND=1
32
- fi
33
-
34
- # Pattern 2: new URL('/api/...', req.nextUrl) or new URL(..., req.nextUrl.origin)
35
- echo ""
36
- echo "Checking for: new URL(..., req.nextUrl) pattern"
37
- if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\([^)]+,\s*req\.nextUrl" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
38
- echo "ERROR: Found 'new URL(..., req.nextUrl)' pattern. Use internalFetch() instead."
39
- ERRORS_FOUND=1
40
- fi
41
-
42
- # Pattern 3: fetch with req.nextUrl.origin and /api/
43
- echo ""
44
- echo "Checking for: fetch with req.nextUrl.origin and /api/"
45
- if grep -rn --include="*.ts" --include="*.tsx" "req\.nextUrl\.origin.*\/api\/" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
46
- echo "ERROR: Found 'req.nextUrl.origin' used with internal API route. Use internalFetch() instead."
47
- ERRORS_FOUND=1
48
- fi
49
-
50
- # Pattern 4: Direct fetch to /api/auth/refresh without using internalRefresh (warning only)
51
- echo ""
52
- echo "Checking for: Direct fetch to /api/auth/refresh (should use internalRefresh)"
53
- if grep -rn --include="*.ts" --include="*.tsx" "fetch.*\/api\/auth\/refresh" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts" | grep -v "internal-api\.ts"; then
54
- echo "WARNING: Found direct fetch to /api/auth/refresh. Consider using internalRefresh() helper."
55
- # Not failing on this one as it might be intentional in some cases (client-side refresh)
56
- fi
57
-
58
- echo ""
59
- echo "========================================================================"
60
-
61
- if [ $ERRORS_FOUND -eq 1 ]; then
62
- echo "FAILED: Dangerous internal URL patterns detected!"
63
- echo ""
64
- echo "Fix: Import and use helpers from @payez/next-mvp/lib/internal-api:"
65
- echo " import { internalFetch, internalRefresh } from '@payez/next-mvp/lib/internal-api';"
66
- echo ""
67
- echo "Why: Server-to-server calls must use INTERNAL_API_URL (http://service.namespace.svc.cluster.local)"
68
- echo " not the external URL from the request (https://api.example.com) which fails in Kubernetes."
69
- exit 1
70
- else
71
- echo "PASSED: No dangerous internal URL patterns found."
72
- exit 0
73
- fi
1
+ #!/bin/bash
2
+ #
3
+ # CI Check: Detect incorrect internal API URL patterns
4
+ #
5
+ # This script fails if code is using req.url or req.nextUrl.origin to construct
6
+ # URLs for internal API calls. These patterns cause 403 errors in Kubernetes
7
+ # because they use the external HTTPS URL instead of INTERNAL_API_URL.
8
+ #
9
+ # CORRECT: Use internalFetch() or internalRefresh() from @payez/next-mvp/lib/internal-api
10
+ # INCORRECT: new URL('/api/...', req.url) or fetch(`${req.nextUrl.origin}/api/...`)
11
+ #
12
+ # NOTE: new URL(req.url) alone is FINE - it just parses the incoming request URL.
13
+ # The problem is when you construct a NEW path using req.url as the base.
14
+ #
15
+
16
+ set -e
17
+
18
+ SEARCH_PATH="${1:-.}"
19
+ ERRORS_FOUND=0
20
+
21
+ echo "Checking for incorrect internal URL patterns in: $SEARCH_PATH"
22
+ echo "========================================================================"
23
+
24
+ # Pattern 1: new URL('/api/...', req.url) - constructing internal API URL from request
25
+ # This catches: new URL('/api/...', req.url) and new URL("/api/...", req.url)
26
+ # But NOT: new URL(req.url) which is just parsing the request
27
+ echo ""
28
+ echo "Checking for: new URL('/api/...', req.url) pattern"
29
+ if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\(['\"][^'\"]*['\"],\s*req\.url" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
30
+ echo "ERROR: Found 'new URL(path, req.url)' pattern. Use internalFetch() instead."
31
+ ERRORS_FOUND=1
32
+ fi
33
+
34
+ # Pattern 2: new URL('/api/...', req.nextUrl) or new URL(..., req.nextUrl.origin)
35
+ echo ""
36
+ echo "Checking for: new URL(..., req.nextUrl) pattern"
37
+ if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\([^)]+,\s*req\.nextUrl" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
38
+ echo "ERROR: Found 'new URL(..., req.nextUrl)' pattern. Use internalFetch() instead."
39
+ ERRORS_FOUND=1
40
+ fi
41
+
42
+ # Pattern 3: fetch with req.nextUrl.origin and /api/
43
+ echo ""
44
+ echo "Checking for: fetch with req.nextUrl.origin and /api/"
45
+ if grep -rn --include="*.ts" --include="*.tsx" "req\.nextUrl\.origin.*\/api\/" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
46
+ echo "ERROR: Found 'req.nextUrl.origin' used with internal API route. Use internalFetch() instead."
47
+ ERRORS_FOUND=1
48
+ fi
49
+
50
+ # Pattern 4: Direct fetch to /api/auth/refresh without using internalRefresh (warning only)
51
+ echo ""
52
+ echo "Checking for: Direct fetch to /api/auth/refresh (should use internalRefresh)"
53
+ if grep -rn --include="*.ts" --include="*.tsx" "fetch.*\/api\/auth\/refresh" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts" | grep -v "internal-api\.ts"; then
54
+ echo "WARNING: Found direct fetch to /api/auth/refresh. Consider using internalRefresh() helper."
55
+ # Not failing on this one as it might be intentional in some cases (client-side refresh)
56
+ fi
57
+
58
+ echo ""
59
+ echo "========================================================================"
60
+
61
+ if [ $ERRORS_FOUND -eq 1 ]; then
62
+ echo "FAILED: Dangerous internal URL patterns detected!"
63
+ echo ""
64
+ echo "Fix: Import and use helpers from @payez/next-mvp/lib/internal-api:"
65
+ echo " import { internalFetch, internalRefresh } from '@payez/next-mvp/lib/internal-api';"
66
+ echo ""
67
+ echo "Why: Server-to-server calls must use INTERNAL_API_URL (http://service.namespace.svc.cluster.local)"
68
+ echo " not the external URL from the request (https://api.example.com) which fails in Kubernetes."
69
+ exit 1
70
+ else
71
+ echo "PASSED: No dangerous internal URL patterns found."
72
+ exit 0
73
+ fi
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 171900 item (2) — PUBLISH-HEALTH CHECK.
4
+ *
5
+ * WHY THIS EXISTS. The npm token for @payez/next-mvp expired 2026-06-14 and was
6
+ * never replaced. Nine versions went unpublished for ~7 weeks and NOBODY NOTICED,
7
+ * because everyone installs from local tarballs — so npm was never a consumer and
8
+ * nothing ever failed. The one-off fix (re-auth, publish 4.1.20) was applied and the
9
+ * drift RESUMED IMMEDIATELY: as of 2026-08-08 the registry still says 4.1.20 while
10
+ * local is 4.1.25, and `npm whoami` returns ENEEDAUTH again.
11
+ *
12
+ * A re-auth is not a fix. The fix is a consumer that FAILS when npm lags — which is
13
+ * this script. Without it, the next silent expiry is invisible for another 7 weeks.
14
+ *
15
+ * DESIGN NOTES (deliberate, do not "helpfully" soften these):
16
+ * - It EXITS NON-ZERO on lag. A check that warns is a check that gets ignored;
17
+ * that is precisely how the original outage survived 7 weeks.
18
+ * - It is NOT wired into prepack/prepublishOnly. Packing must not require the
19
+ * network, and a build that breaks when npm is unreachable trades one outage
20
+ * for another. Run it in CI/release, or by hand before a release.
21
+ * - Missing auth is reported as its OWN outcome, not folded into "lagging".
22
+ * Those are different failures with different fixes (credential vs publish).
23
+ *
24
+ * Usage: node scripts/check-publish-health.mjs
25
+ * Exit: 0 = registry is at or ahead of local
26
+ * 1 = registry LAGS local (unpublished versions exist)
27
+ * 2 = could not determine (network/registry/parse) — inconclusive, NOT a pass
28
+ */
29
+
30
+ import { execFileSync } from 'node:child_process';
31
+ import { readFileSync } from 'node:fs';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { dirname, join } from 'node:path';
34
+
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ const pkgPath = join(here, '..', 'package.json');
37
+
38
+ function run(args) {
39
+ return execFileSync('npm', args, {
40
+ encoding: 'utf8',
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ shell: process.platform === 'win32',
43
+ }).trim();
44
+ }
45
+
46
+ function sleepSync(ms) {
47
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
+ }
49
+
50
+ /** Numeric-aware semver compare. Returns >0 if a > b, 0 if equal, <0 if a < b. */
51
+ function compareVersions(a, b) {
52
+ const norm = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
53
+ const [x, y] = [norm(a), norm(b)];
54
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
55
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
56
+ if (d !== 0) return d;
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ let local;
62
+ try {
63
+ local = JSON.parse(readFileSync(pkgPath, 'utf8'));
64
+ } catch (err) {
65
+ console.error(`[publish-health] INCONCLUSIVE: cannot read ${pkgPath}: ${err.message}`);
66
+ process.exit(2);
67
+ }
68
+
69
+ const name = local.name;
70
+ const localVersion = local.version;
71
+
72
+ // Auth is reported separately: a dead credential is the ROOT of 171900, and folding
73
+ // it into "lagging" would hide which of the two things is actually broken.
74
+ let authUser = null;
75
+ try {
76
+ authUser = run(['whoami']);
77
+ } catch {
78
+ authUser = null;
79
+ }
80
+
81
+ // READ THE REGISTRY, NOT npm's CACHE (measured 2026-09-10, Nextpert-Scout).
82
+ //
83
+ // This script is wired as `postpublish` in package.json. postpublish runs ONLY after
84
+ // a SUCCESSFUL upload — so the one moment it is guaranteed to run is the one moment
85
+ // npm's metadata cache is guaranteed to be stale. Publishing 4.4.0 produced:
86
+ //
87
+ // [publish-health] FAIL: the registry LAGS local by at least one version.
88
+ // [publish-health] 4.4.0 is built locally but the registry serves 4.1.21.
89
+ // npm error code 1
90
+ //
91
+ // 4.4.0 was already live. THE HONEST REACTION TO THAT OUTPUT IS TO PUBLISH AGAIN,
92
+ // which is the dangerous action — and against a registry that refuses republication
93
+ // it burns a version number, which is how a version string ends up naming two trees.
94
+ //
95
+ // `--prefer-online` forces revalidation. The bounded retry covers real propagation
96
+ // delay, which is seconds, not weeks.
97
+ //
98
+ // THIS DOES NOT SOFTEN THE CHECK, and it must not be "helpfully" turned into a warning:
99
+ // a genuine lag (the 171900 failure mode — a dead credential leaving versions
100
+ // unpublished for WEEKS) still exhausts every attempt and still exits non-zero. The
101
+ // retry changes how accurately we read the registry, never what counts as a failure.
102
+ const REGISTRY_ATTEMPTS = 4;
103
+ const REGISTRY_RETRY_MS = 2000;
104
+
105
+ let registryVersion = null;
106
+ for (let attempt = 1; attempt <= REGISTRY_ATTEMPTS; attempt++) {
107
+ try {
108
+ registryVersion = run(['view', name, 'version', '--prefer-online']);
109
+ } catch (err) {
110
+ const msg = String(err.stderr || err.message || '');
111
+ if (/E404|404 Not Found/.test(msg)) {
112
+ console.error(`[publish-health] FAIL: ${name} has NEVER been published to the registry.`);
113
+ console.error(`[publish-health] local=${localVersion} registry=<absent>`);
114
+ process.exit(1);
115
+ }
116
+ console.error(`[publish-health] INCONCLUSIVE: could not query the registry: ${msg.split('\n')[0]}`);
117
+ console.error('[publish-health] Treat as UNKNOWN, not as a pass.');
118
+ process.exit(2);
119
+ }
120
+
121
+ // Stop as soon as the registry is at or ahead of local. Only an apparent lag is
122
+ // worth re-reading, and only because THIS script's own hook makes a stale read likely.
123
+ if (compareVersions(localVersion, registryVersion) <= 0) break;
124
+
125
+ if (attempt < REGISTRY_ATTEMPTS) {
126
+ console.log(
127
+ `[publish-health] registry says ${registryVersion}, local is ${localVersion} — ` +
128
+ `re-reading (${attempt}/${REGISTRY_ATTEMPTS - 1}) in case this is post-publish propagation lag.`,
129
+ );
130
+ sleepSync(REGISTRY_RETRY_MS);
131
+ }
132
+ }
133
+
134
+ const delta = compareVersions(localVersion, registryVersion);
135
+
136
+ console.log(`[publish-health] package : ${name}`);
137
+ console.log(`[publish-health] local : ${localVersion}`);
138
+ console.log(`[publish-health] registry : ${registryVersion}`);
139
+ console.log(`[publish-health] npm auth : ${authUser ? `logged in as ${authUser}` : 'NOT AUTHENTICATED (npm whoami failed)'}`);
140
+
141
+ if (delta > 0) {
142
+ console.error('');
143
+ console.error(`[publish-health] FAIL: the registry LAGS local by at least one version.`);
144
+ console.error(`[publish-health] ${localVersion} is built locally but the registry serves ${registryVersion}.`);
145
+ if (!authUser) {
146
+ console.error('[publish-health] ROOT CAUSE LIKELY: npm is not authenticated on this machine.');
147
+ console.error('[publish-health] This is the 171900 failure mode — a silent credential expiry.');
148
+ }
149
+ console.error('[publish-health] Unpublished versions are invisible to every consumer that is not');
150
+ console.error('[publish-health] installing from a local tarball. See cards 171900 / 185177.');
151
+ process.exit(1);
152
+ }
153
+
154
+ if (!authUser) {
155
+ console.error('');
156
+ console.error('[publish-health] FAIL: registry is current, but npm is NOT AUTHENTICATED here.');
157
+ console.error('[publish-health] The next publish will fail silently in exactly the 171900 way.');
158
+ process.exit(1);
159
+ }
160
+
161
+ console.log('[publish-health] OK: registry is at or ahead of local, and auth is live.');
162
+ process.exit(0);
@@ -1,15 +1,34 @@
1
1
  # Dev profile: prod-like broker bootstrap via KV-signed client_assertion
2
2
  # Usage: powershell -File dev-broker.ps1 -ClientId "my_app" -Port 3000 -AppName "MyApp"
3
- # This script enables broker mode for PayEz MVP authentication
4
3
  #
5
- # IMPORTANT: Broker mode provides production-like OAuth/OIDC authentication
4
+ # IMPORTANT: Broker mode provides production-like OAuth/OIDC authentication.
5
+ #
6
+ # THIS SCRIPT PRINTS WHICH IDP IT IS TALKING TO AND WHAT THAT IDP SAYS ABOUT THE
7
+ # CLIENT. Jon 2026-08-26: "make sure the broker script returns the idp client
8
+ # details into the log, it's important for devs to see that."
9
+ #
10
+ # WHY IT MATTERS: the same app can be pointed at different IDP hosts, and the only
11
+ # difference visible in a terminal used to be a port number. Printing the host and
12
+ # what that host says about this client makes it impossible to mistake.
6
13
 
7
14
  param(
8
- [string]$ClientId = "2",
15
+ [string]$ClientId = "",
9
16
  [int]$Port = 3000,
10
17
  [string]$AppName = "PayEz MVP"
11
18
  )
12
19
 
20
+ # NO NUMERIC DEFAULT. This used to default to "2" - a numeric id, on a system that
21
+ # identifies clients BY SLUG (card 268410: the login door was numeric-only at five
22
+ # sites). A silent numeric default means a dev who forgets the flag authenticates
23
+ # against whatever client 2 happens to be, and the failure looks like a config bug.
24
+ if ([string]::IsNullOrWhiteSpace($ClientId)) {
25
+ Write-Host "ERROR: -ClientId is required and must be the client SLUG, e.g. umi_browser_website" -ForegroundColor Red
26
+ Write-Host " (there is deliberately no default - a numeric fallback authenticates you" -ForegroundColor Red
27
+ Write-Host " against the wrong tenant and reads as a config bug)" -ForegroundColor Red
28
+ exit 1
29
+ }
30
+
31
+ Write-Host ""
13
32
  Write-Host "Starting $AppName in BROKER mode..." -ForegroundColor Cyan
14
33
 
15
34
  # Kill any existing process on the specified port
@@ -24,11 +43,57 @@ if ($connections) {
24
43
  }
25
44
 
26
45
  # Set required environment variables for broker mode
27
- $env:NEXT_CLIENT_ID = "$ClientId"
46
+ # MEASURED 2026-08-26: this script set ONLY $env:NEXT_CLIENT_ID, and NOTHING on the
47
+ # auth path reads that name - grep of the shipped dist finds it in demo-mode.js alone.
48
+ # getIDPClientConfig() reads CLIENT_ID or NEXT_PUBLIC_CLIENT_ID and threw
49
+ # "CLIENT_ID or NEXT_PUBLIC_CLIENT_ID must be set" the moment anything actually called
50
+ # it. Three names for one value, and the script was setting the one that does not count.
51
+ # All three are set here so no consumer is left guessing; the duplication is the bug,
52
+ # and collapsing the names is a package change, not a script change.
53
+ $env:CLIENT_ID = "$ClientId"
54
+ $env:NEXT_PUBLIC_CLIENT_ID = "$ClientId"
55
+ $env:NEXT_CLIENT_ID = "$ClientId" # legacy: demo-mode.js only
28
56
  $env:USE_BROKER_MODE = "true"
29
57
 
30
- Write-Host "Client ID: $ClientId" -ForegroundColor Green
31
- Write-Host "Broker mode: ENABLED" -ForegroundColor Green
58
+ # ── WHICH IDP ARE WE ACTUALLY TALKING TO? ────────────────────────────────────
59
+ # Read it, never guess it. An unset IDP_URL is reported as UNSET rather than
60
+ # quietly substituted, because a substituted host is the whole problem.
61
+ # MEASURED 2026-08-26: reading only $env: reported "<UNSET>" on UMI while
62
+ # .env.development held https://idp.payez.net. Next.js loads the .env files ITSELF
63
+ # at startup, so the shell that runs this script never sees them - and a banner that
64
+ # confidently prints the wrong leg is worse than printing nothing. Read the files in
65
+ # the SAME PRECEDENCE Next uses, then let a real shell variable win (it does there too).
66
+ function Get-EnvValue([string]$Name) {
67
+ # NOT `$env:$Name` - PowerShell cannot interpolate a variable into an env lookup
68
+ # that way; it parses as $env: followed by $Name and silently yields nothing.
69
+ $fromShell = [Environment]::GetEnvironmentVariable($Name)
70
+ if (-not [string]::IsNullOrWhiteSpace($fromShell)) { return $fromShell }
71
+ foreach ($f in @('.env.local', ".env.$($env:NODE_ENV)", '.env.development', '.env')) {
72
+ if ([string]::IsNullOrWhiteSpace($f)) { continue }
73
+ if (-not (Test-Path $f)) { continue }
74
+ foreach ($line in (Get-Content $f -ErrorAction SilentlyContinue)) {
75
+ if ($line -match "^\s*$([regex]::Escape($Name))\s*=\s*(.+?)\s*$") {
76
+ return $matches[1].Trim("'").Trim('"')
77
+ }
78
+ }
79
+ }
80
+ return $null
81
+ }
82
+
83
+ $idpUrl = Get-EnvValue 'IDP_URL'
84
+ if ([string]::IsNullOrWhiteSpace($idpUrl)) { $idpUrl = "<UNSET>" }
85
+
86
+ # NO BANNER HERE. The real readout is logStartupStatus() + ensureInitialized(), printed
87
+ # by the app's own instrumentation.ts hook when Next boots - client id, slug, secret
88
+ # length, OAuth providers, require2FA, cache TTL, base URL. That is the report devs read.
89
+ #
90
+ # A second banner in this script was a REIMPLEMENTATION of it, and it drifted immediately:
91
+ # it read .env directly, reported <UNSET> for a value the app had, and claimed things about
92
+ # provisioning it had not established. One reporter, at the point the app actually connects.
93
+ #
94
+ # If nothing prints below, the app has no instrumentation.ts wired - copy
95
+ # node_modules/@payez/next-mvp/src/templates/instrumentation.ts to the project root.
96
+
32
97
  Write-Host "Starting Next.js dev server on port $Port..." -ForegroundColor Cyan
33
98
 
34
99
  # Use npx directly to avoid npm batch job nesting issues
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Print what the IDP says about THIS client, using the real broker handshake.
3
+ *
4
+ * Jon 2026-08-26: "the main thing I am looking for is the response from the IDP about
5
+ * the tenant app - does it require 2fa, does it do google oauth. I could give a shit
6
+ * about the .env.development that I just set before starting the site."
7
+ *
8
+ * So this does NOT print local configuration. It performs the same two-step the app
9
+ * performs -- POST /api/ExternalAuth/sign-client-assertion, then POST
10
+ * /api/ExternalAuth/client-config with that assertion -- by calling the package's OWN
11
+ * getIDPClientConfig(). Reimplementing the handshake here would be a second copy of
12
+ * the one thing that must not drift.
13
+ *
14
+ * HOW IT LOADS THE MODULE, AND WHY IT LOOKS LIKE THIS:
15
+ * The package is CommonJS (no "type": "module", main: dist/index.js) and its
16
+ * package.json `exports` map does NOT list ./dist/lib/*, so a subpath import is
17
+ * BLOCKED by Node's exports resolution. Requiring the file by ABSOLUTE PATH bypasses
18
+ * the map, which is the only route to a module the package does not publish.
19
+ *
20
+ * An earlier version wrapped the import in .catch(() => import('@payez/next-mvp')) and
21
+ * reported "not exported" -- a fallback that replaced a precise error (ERR_PACKAGE_PATH_
22
+ * NOT_EXPORTED) with a wrong one. There are no fallbacks here now: each step fails with
23
+ * its own reason.
24
+ */
25
+ import { createRequire } from 'node:module';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const pad = (k) => String(k).padEnd(24);
30
+ const line = () => console.log('-'.repeat(62));
31
+
32
+ function fail(msg) {
33
+ loud();
34
+ line();
35
+ // FIRST LINE ONLY. A stack trace buries the sentence the reader needs, and the
36
+ // file:line is already in the raw stderr above for anyone who wants it.
37
+ const m = String(msg).split(String.fromCharCode(10))[0].replace(/^Error:\s*/, '');
38
+
39
+ // DISTINGUISH THE TWO FAILURES. "The IDP never answered" and "the IDP answered and
40
+ // this client is barely provisioned" are different facts, and the second is the one
41
+ // a dev can act on. Jon 2026-08-26: "that is fine if they are not there, it's the
42
+ // clearly knowing that or not knowing that that is the issue."
43
+ const answered = /authSecret is empty|missing client_assertion|after parsing/i.test(m);
44
+
45
+ if (answered) {
46
+ // SPECIFIC, NOT GENERAL. Report the fields the IDP actually returned and name the
47
+ // one that is absent. Jon 2026-08-26: a vague "not provisioned" was wrong - the
48
+ // client record is fully configured. State the fact, not a conclusion drawn from it.
49
+ const rawKeys = (captured.find((l) => l.includes('raw keys:')) || '')
50
+ .split('raw keys:')[1]?.trim() ?? '(not reported)';
51
+
52
+ console.log(' IDP ANSWERED - RESPONSE INCOMPLETE FOR BROKER MODE');
53
+ line();
54
+ console.log(' Fields the IDP returned : ' + rawKeys);
55
+ console.log(' Field broker mode needs : authSecret <- ABSENT');
56
+ console.log('');
57
+ console.log(' The client record exists and is active. The broker secret is');
58
+ console.log(' provisioned separately from it, and is not present here.');
59
+ console.log('');
60
+ console.log(' NOT DETERMINED by this run - the handshake stopped before reading them:');
61
+ console.log(' require 2FA, allowed 2FA methods, federated OAuth providers,');
62
+ console.log(' public registration, password reset.');
63
+ console.log(' Do not infer any of those from this message.');
64
+ console.log('');
65
+ console.log(' IDP said: ' + m);
66
+ console.log('');
67
+ console.log(' TO FIX: provision the broker secret for this client, then rerun.');
68
+ console.log(' The app still starts - it just cannot read its sign-in policy yet.');
69
+ } else {
70
+ console.log(' IDP CLIENT CONFIG: HANDSHAKE FAILED');
71
+ line();
72
+ console.log(' ' + m);
73
+ console.log(' The IDP could not be reached, or refused the assertion. You do NOT');
74
+ console.log(' know what this tenant requires.');
75
+ }
76
+ line();
77
+ process.exitCode = 1;
78
+ }
79
+
80
+ // The package logs its own fetch-failure object to console.error. This script is a
81
+ // BANNER, not the app: keep stderr for real faults so the box below is the answer.
82
+ const _err = console.error, _log = console.log;
83
+ const captured = [];
84
+ const quiet = () => {
85
+ console.error = (...a) => captured.push(a.map(String).join(' '));
86
+ console.log = (...a) => captured.push(a.map(String).join(' '));
87
+ };
88
+ const loud = () => { console.error = _err; console.log = _log; };
89
+ quiet();
90
+
91
+ try {
92
+ const require = createRequire(path.join(process.cwd(), 'noop.js'));
93
+
94
+ // Resolve the package ROOT via its main entry, then reach the unexported module by
95
+ // absolute path. require.resolve on the bare name honours `exports`; on an absolute
96
+ // path it does not.
97
+ const mainEntry = require.resolve('@payez/next-mvp'); // <root>/dist/index.js
98
+ const distDir = path.dirname(mainEntry); // <root>/dist
99
+ const target = path.join(distDir, 'lib', 'idp-client-config.js');
100
+
101
+ // `server-only` is a MARKER package: importing it from a client bundle is meant to
102
+ // throw. It has no runtime behaviour on the server, and it is not always hoisted into
103
+ // a consumer's node_modules. This script IS a server context, so the marker is
104
+ // satisfied explicitly with an empty module rather than the require being wrapped in
105
+ // a catch that would hide a genuinely missing dependency.
106
+ const Module = require('node:module');
107
+ const origResolve = Module._resolveFilename;
108
+ Module._resolveFilename = function (request, ...rest) {
109
+ if (request === 'server-only' || request === 'client-only') {
110
+ // must be a REAL FILE PATH. fileURLToPath, not manual URL parsing: this folder
111
+ // is "UMI - Website" and a raw import.meta.url keeps the space as %20, which
112
+ // then ENOENTs on a path that looks correct in the error message.
113
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), 'server-only-stub.cjs');
114
+ }
115
+ return origResolve.call(this, request, ...rest);
116
+ };
117
+
118
+ let mod;
119
+ try {
120
+ mod = require(target);
121
+ } catch (e) {
122
+ fail(`could not load ${target}\n ${e.message}`);
123
+ }
124
+
125
+ const getCfg = mod.getIDPClientConfig ?? mod.default?.getIDPClientConfig;
126
+ if (typeof getCfg !== 'function') {
127
+ fail(`getIDPClientConfig missing from ${target}. Exports present: ` +
128
+ Object.keys(mod).join(', '));
129
+ }
130
+
131
+ const cfg = await getCfg(true);
132
+ console.error = _err; // forceRefresh: never report a cached answer
133
+
134
+ line();
135
+ console.log(' AS REPORTED BY THE IDP (broker handshake, signed client_assertion)');
136
+ line();
137
+
138
+ console.log(pad(' require 2FA') + ': ' + cfg.require2FA);
139
+ console.log(pad(' allowed 2FA methods') + ': ' +
140
+ ((cfg.allowed2FAMethods ?? []).join(', ') || '(none)'));
141
+ console.log(pad(' MFA grace period') + ': ' + cfg.mfaGracePeriodHours + 'h');
142
+ console.log(pad(' remember device') + ': ' + cfg.mfaRememberDeviceDays + 'd');
143
+
144
+ const provs = cfg.oauthProviders ?? [];
145
+ const on = provs.filter((p) => p.enabled).map((p) => p.provider ?? p.name ?? '?');
146
+ const off = provs.filter((p) => !p.enabled).map((p) => p.provider ?? p.name ?? '?');
147
+ console.log(pad(' OAuth providers ON') + ': ' + (on.join(', ') || '(none)'));
148
+ if (off.length) console.log(pad(' OAuth providers off') + ': ' + off.join(', '));
149
+
150
+ for (const [label, key] of [
151
+ ['social login', 'allowSocialLogin'],
152
+ ['public registration', 'allowPublicRegistration'],
153
+ ['password reset', 'enablePasswordReset'],
154
+ ['remember me', 'allowRememberMe'],
155
+ ]) {
156
+ if (cfg[key] !== undefined) console.log(pad(' ' + label) + ': ' + cfg[key]);
157
+ }
158
+ line();
159
+ process.exitCode = 0;
160
+ } catch (err) {
161
+ fail(err?.stack ?? err?.message ?? String(err));
162
+ }
@@ -0,0 +1,4 @@
1
+ // Empty stand-in for the `server-only` / `client-only` MARKER packages.
2
+ // They have no runtime behaviour; they exist so a bundler can fail a bad import.
3
+ // A node script running server-side satisfies the marker by definition.
4
+ module.exports = {};
@@ -1,4 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import { getIDPClientConfig } from '../../../lib/idp-client-config';
3
+ import { resolveClientKey } from '../../../lib/client-key';
2
4
 
3
5
  function getIdpUrl(): string {
4
6
  const url = process.env.IDP_URL;
@@ -6,14 +8,45 @@ function getIdpUrl(): string {
6
8
  return url;
7
9
  }
8
10
 
11
+ /**
12
+ * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
13
+ *
14
+ * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
15
+ * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
16
+ * recovery token was ever minted. That is why S12 failed at step 1.
17
+ *
18
+ * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
19
+ * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
20
+ * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
21
+ * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
22
+ * added four more sites to the 268410 family and made a stale id answer for
23
+ * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
24
+ * the numeric fallback logged, is the same resolver the other nine sites use.
25
+ *
26
+ * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
27
+ * These are unauthenticated recovery endpoints, so a browser-supplied
28
+ * client_id would be a tenant-targeting lever. The caller does not get to
29
+ * choose which tenant their password reset is addressed to.
30
+ */
31
+ async function recoveryClientKey(): Promise<string | null> {
32
+ try {
33
+ const config = await getIDPClientConfig();
34
+ return resolveClientKey(config, 'account/recovery/initiate').key;
35
+ } catch (e) {
36
+ console.error('[account/recovery/initiate] could not resolve IDP client config for client_id:', e);
37
+ return null;
38
+ }
39
+ }
40
+
9
41
  export async function POST(req: NextRequest) {
10
42
  const IDP_URL = getIdpUrl();
11
43
  try {
12
44
  const { email } = await req.json();
45
+ const clientKey = await recoveryClientKey();
13
46
  const upstream = await fetch(`${IDP_URL}/api/Account/recovery/initiate`, {
14
47
  method: 'POST',
15
- headers: { 'Content-Type': 'application/json' },
16
- body: JSON.stringify({ email }),
48
+ headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
49
+ body: JSON.stringify({ email, ...(clientKey ? { client_id: clientKey } : {}) }),
17
50
  });
18
51
  const data = await upstream.json().catch(() => ({}));
19
52
  return NextResponse.json(data, { status: upstream.status });