@payez/next-mvp 4.8.1 → 4.8.3

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 (314) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/api-handlers/admin/audit.js +13 -21
  4. package/dist/api-handlers/admin/sessions.js +59 -45
  5. package/dist/api-handlers/admin/stats.js +1 -1
  6. package/dist/api-handlers/admin/users.js +15 -23
  7. package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
  8. package/dist/api-handlers/admin/vibe-data.js +5 -0
  9. package/dist/components/account/MobileNavDrawer.js +18 -18
  10. package/dist/components/account/UserAvatarMenu.js +1 -1
  11. package/dist/components/admin/AlertSettingsTab.js +40 -40
  12. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  13. package/dist/lib/session-store.js +21 -21
  14. package/dist/lib/startup-init.js +19 -19
  15. package/dist/pages/login/page.js +28 -28
  16. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  17. package/dist/vibe/client.d.ts +1 -1
  18. package/dist/vibe/client.js +2 -1
  19. package/dist/vibe/filters.d.ts +36 -0
  20. package/dist/vibe/filters.js +47 -0
  21. package/dist/vibe/generic.js +2 -1
  22. package/dist/vibe/sessions.js +28 -3
  23. package/package.json +2 -2
  24. package/scripts/check-publish-eol.mjs +33 -15
  25. package/src/api/auth-handler.ts +550 -550
  26. package/src/api/index.ts +17 -17
  27. package/src/api-handlers/account/change-password.ts +170 -170
  28. package/src/api-handlers/account/masked-info.ts +46 -46
  29. package/src/api-handlers/account/profile.ts +99 -99
  30. package/src/api-handlers/account/recovery/forward.ts +117 -117
  31. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  32. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  33. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  34. package/src/api-handlers/account/reset-password.ts +87 -87
  35. package/src/api-handlers/account/send-code.ts +70 -70
  36. package/src/api-handlers/account/update-phone.ts +84 -84
  37. package/src/api-handlers/account/validate-password.ts +139 -139
  38. package/src/api-handlers/account/verify-email.ts +134 -134
  39. package/src/api-handlers/account/verify-sms.ts +134 -134
  40. package/src/api-handlers/admin/analytics.ts +452 -452
  41. package/src/api-handlers/admin/audit.ts +223 -232
  42. package/src/api-handlers/admin/index.ts +63 -63
  43. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  44. package/src/api-handlers/admin/sessions.ts +341 -327
  45. package/src/api-handlers/admin/site-logs.ts +388 -388
  46. package/src/api-handlers/admin/stats.ts +260 -260
  47. package/src/api-handlers/admin/users.ts +240 -251
  48. package/src/api-handlers/admin/vibe-data.ts +338 -333
  49. package/src/api-handlers/anon/preferences.ts +123 -123
  50. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  51. package/src/api-handlers/auth/credential-login.ts +948 -948
  52. package/src/api-handlers/auth/jwks.ts +31 -31
  53. package/src/api-handlers/auth/login.ts +252 -252
  54. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  55. package/src/api-handlers/auth/refresh.ts +829 -829
  56. package/src/api-handlers/auth/signout.ts +201 -201
  57. package/src/api-handlers/auth/status.ts +23 -23
  58. package/src/api-handlers/auth/update-session.ts +144 -144
  59. package/src/api-handlers/auth/validate.ts +45 -45
  60. package/src/api-handlers/auth/verify-code.ts +116 -116
  61. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  62. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  63. package/src/api-handlers/mfa/methods.ts +47 -47
  64. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  65. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  66. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  67. package/src/api-handlers/oauth/resume.ts +182 -182
  68. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  69. package/src/api-handlers/onboarding/set-password.ts +21 -21
  70. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  71. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  72. package/src/api-handlers/passkeys/list.ts +32 -32
  73. package/src/api-handlers/passkeys/manage.ts +55 -55
  74. package/src/api-handlers/passkeys/register-options.ts +43 -43
  75. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  76. package/src/api-handlers/session/refresh-viability.ts +56 -56
  77. package/src/api-handlers/session/viability.ts +238 -238
  78. package/src/api-handlers/test/force-expire.ts +167 -167
  79. package/src/auth/auth-decision.ts +292 -292
  80. package/src/auth/better-auth.ts +1688 -1688
  81. package/src/auth/idp-oauth.ts +310 -310
  82. package/src/auth/password-reset-bridge.ts +98 -98
  83. package/src/auth/revocation-fields.ts +16 -16
  84. package/src/auth/route-config.ts +236 -236
  85. package/src/auth/safe-callback.ts +199 -199
  86. package/src/auth/session-cookie-clear.ts +30 -30
  87. package/src/auth/session-reset-link.ts +159 -159
  88. package/src/auth/session-response-sanitizer.ts +151 -151
  89. package/src/auth/types/auth-types.ts +555 -555
  90. package/src/auth/types/index.ts +7 -7
  91. package/src/auth/unauthenticated-routes.ts +2 -2
  92. package/src/auth/utils/idp-client.ts +364 -364
  93. package/src/auth/utils/index.ts +6 -6
  94. package/src/auth/utils/token-utils.ts +243 -243
  95. package/src/client/AuthContext.tsx +192 -192
  96. package/src/client/better-auth-client.ts +70 -70
  97. package/src/client/fetch-with-auth.ts +47 -47
  98. package/src/client/fetchWithSession.ts +21 -21
  99. package/src/client/index.ts +13 -13
  100. package/src/client/useAnonSession.ts +131 -131
  101. package/src/components/SessionSync.tsx +138 -138
  102. package/src/components/SignalRHealthCheck.tsx +131 -131
  103. package/src/components/account/MobileNavDrawer.tsx +305 -305
  104. package/src/components/account/UserAvatarMenu.tsx +277 -277
  105. package/src/components/account/index.ts +13 -13
  106. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  107. package/src/components/admin/AnalyticsTab.tsx +703 -703
  108. package/src/components/admin/DataBrowserTab.tsx +508 -508
  109. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  110. package/src/components/admin/SessionsTab.tsx +421 -421
  111. package/src/components/admin/StatsTab.tsx +379 -379
  112. package/src/components/admin/VibeAdminContext.tsx +87 -87
  113. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  114. package/src/components/admin/index.ts +59 -59
  115. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  116. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  117. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  118. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  119. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  120. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  121. package/src/components/recovery/CompleteStep.tsx +36 -36
  122. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  123. package/src/components/recovery/RequestedStep.tsx +30 -30
  124. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  125. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  126. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  127. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  128. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  129. package/src/components/ui/BetaBadge.tsx +58 -58
  130. package/src/components/ui/Footer.tsx +93 -93
  131. package/src/config/env.ts +55 -55
  132. package/src/config/logger.ts +62 -62
  133. package/src/config/logging-config.ts +82 -82
  134. package/src/config/unauthenticated-routes.ts +19 -19
  135. package/src/config/vibe-log-transport.ts +264 -264
  136. package/src/edge/internal-api-url.ts +65 -65
  137. package/src/edge/middleware.ts +42 -42
  138. package/src/hooks/useAuth.ts +117 -117
  139. package/src/hooks/useAuthSettings.ts +109 -109
  140. package/src/hooks/useAvailableProviders.ts +143 -143
  141. package/src/hooks/usePasswordValidation.ts +127 -127
  142. package/src/hooks/useProfile.ts +82 -82
  143. package/src/hooks/usePublicAuthSettings.ts +171 -171
  144. package/src/hooks/useSessionExpiration.ts +101 -101
  145. package/src/hooks/useViabilitySession.ts +337 -337
  146. package/src/index.ts +64 -64
  147. package/src/lib/anon-session.ts +213 -213
  148. package/src/lib/api-handler.ts +643 -643
  149. package/src/lib/app-slug.ts +228 -228
  150. package/src/lib/authorize-resume.ts +172 -172
  151. package/src/lib/bearer-fetch.ts +31 -31
  152. package/src/lib/client-key.ts +88 -88
  153. package/src/lib/code-step-copy.ts +83 -83
  154. package/src/lib/credential-sign-in-client.ts +101 -101
  155. package/src/lib/demo-mode.ts +18 -18
  156. package/src/lib/device-id.ts +67 -67
  157. package/src/lib/ensure-fresh-access-token.ts +509 -509
  158. package/src/lib/geolocation.ts +265 -265
  159. package/src/lib/idp-client-config.ts +1005 -1005
  160. package/src/lib/idp-fetch.ts +169 -169
  161. package/src/lib/idp-url-guard.ts +37 -37
  162. package/src/lib/internal-api.ts +171 -171
  163. package/src/lib/jwt-decode-client.ts +45 -45
  164. package/src/lib/jwt-decode.ts +82 -82
  165. package/src/lib/lock-window.ts +49 -49
  166. package/src/lib/login-error-message.ts +41 -41
  167. package/src/lib/never-in-response.ts +64 -64
  168. package/src/lib/nextauth-secret.ts +168 -168
  169. package/src/lib/onboarding-proxy.ts +57 -57
  170. package/src/lib/password-rules.ts +50 -50
  171. package/src/lib/rate-limit-service.ts +9 -9
  172. package/src/lib/redis.ts +105 -105
  173. package/src/lib/refresh-token-validator.ts +64 -64
  174. package/src/lib/roles.ts +177 -177
  175. package/src/lib/safe-error-response.ts +149 -149
  176. package/src/lib/secret-validation.ts +8 -8
  177. package/src/lib/session-cookie-shape.ts +185 -185
  178. package/src/lib/session-store.ts +1092 -1092
  179. package/src/lib/session.ts +34 -34
  180. package/src/lib/single-flight.ts +64 -64
  181. package/src/lib/site-logger.ts +245 -245
  182. package/src/lib/standardized-client-api.ts +901 -901
  183. package/src/lib/startup-init.ts +276 -276
  184. package/src/lib/test-aware-get-token.ts +41 -41
  185. package/src/lib/token-expiry.ts +40 -40
  186. package/src/lib/token-lifecycle.ts +546 -546
  187. package/src/lib/token-log-id.ts +69 -69
  188. package/src/lib/twofa-rejection.ts +91 -91
  189. package/src/lib/types/api-responses.ts +336 -336
  190. package/src/lib/user-agent-parser.ts +252 -252
  191. package/src/logging/api/admin-analytics.ts +51 -51
  192. package/src/logging/api/audit-log.ts +53 -53
  193. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  194. package/src/logging/components/AuditLogViewer.tsx +125 -125
  195. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  196. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  197. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  198. package/src/logging/hooks/useAuditLog.ts +24 -24
  199. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  200. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  201. package/src/logging/index.ts +18 -18
  202. package/src/logging/types/analytics.ts +81 -81
  203. package/src/logging/types/audit.ts +31 -31
  204. package/src/logging/types/index.ts +3 -3
  205. package/src/middleware/create-middleware.ts +1141 -1141
  206. package/src/middleware/rbac-check.ts +279 -279
  207. package/src/middleware/twofa-presets.ts +224 -224
  208. package/src/models/DecodedAccessToken.ts +16 -16
  209. package/src/models/SessionModel.ts +331 -331
  210. package/src/pages/admin-login/page.tsx +111 -111
  211. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  212. package/src/pages/admin-page-permissions/index.ts +7 -7
  213. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  214. package/src/pages/admin-roles/index.ts +9 -9
  215. package/src/pages/admin-roles/modals.tsx +469 -469
  216. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  217. package/src/pages/client-admin/index.ts +33 -33
  218. package/src/pages/coming-soon/page.tsx +83 -83
  219. package/src/pages/login/page.tsx +338 -338
  220. package/src/pages/onboarding/index.ts +11 -11
  221. package/src/pages/onboarding/page.tsx +212 -212
  222. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  223. package/src/pages/profile/index.ts +9 -9
  224. package/src/pages/profile/page.tsx +166 -166
  225. package/src/pages/recovery/page.tsx +267 -267
  226. package/src/pages/roles/MyRolesPage.tsx +211 -211
  227. package/src/pages/roles/components.tsx +294 -294
  228. package/src/pages/roles/index.ts +17 -17
  229. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  230. package/src/pages/security/index.ts +12 -12
  231. package/src/pages/security/page.tsx +507 -507
  232. package/src/pages/service-unavailable/page.tsx +63 -63
  233. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  234. package/src/pages/settings/index.ts +9 -9
  235. package/src/pages/settings/page.tsx +47 -47
  236. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  237. package/src/pages/showcase/index.ts +13 -13
  238. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  239. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  240. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  241. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  242. package/src/pages/test-env/guarded.tsx +81 -81
  243. package/src/pages/test-env/index.ts +77 -77
  244. package/src/pages/test-env/redact-credentials.ts +96 -96
  245. package/src/pages/verify-code/page.tsx +746 -746
  246. package/src/routes/account/index.ts +31 -31
  247. package/src/routes/account/masked-info.ts +36 -36
  248. package/src/routes/account/send-code.ts +39 -39
  249. package/src/routes/account/update-phone.ts +13 -13
  250. package/src/routes/account/verify-email.ts +40 -40
  251. package/src/routes/account/verify-sms.ts +40 -40
  252. package/src/routes/auth/credential-login-email-code.ts +6 -6
  253. package/src/routes/auth/credential-login.ts +6 -6
  254. package/src/routes/auth/index.ts +22 -22
  255. package/src/routes/auth/logout.ts +111 -111
  256. package/src/routes/auth/nextauth.ts +45 -45
  257. package/src/routes/auth/passkey-signin.ts +13 -13
  258. package/src/routes/auth/refresh.ts +77 -77
  259. package/src/routes/auth/session-reset.ts +37 -37
  260. package/src/routes/auth/session.ts +261 -261
  261. package/src/routes/auth/settings.ts +181 -181
  262. package/src/routes/auth/viability.ts +519 -519
  263. package/src/routes/index.ts +17 -17
  264. package/src/routes/mfa/backup-code-verify.ts +1 -1
  265. package/src/routes/mfa/backup-codes.ts +10 -10
  266. package/src/routes/mfa/methods.ts +22 -22
  267. package/src/routes/mfa/totp-confirm.ts +10 -10
  268. package/src/routes/mfa/totp-enroll.ts +10 -10
  269. package/src/routes/mfa/totp-verify.ts +14 -14
  270. package/src/routes/onboarding/index.ts +15 -15
  271. package/src/routes/onboarding/send-email-code.ts +2 -2
  272. package/src/routes/onboarding/set-password.ts +2 -2
  273. package/src/routes/onboarding/verify-code.ts +2 -2
  274. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  275. package/src/routes/passkeys/list.ts +10 -10
  276. package/src/routes/passkeys/manage.ts +23 -23
  277. package/src/routes/passkeys/register-options.ts +10 -10
  278. package/src/routes/passkeys/register-verify.ts +10 -10
  279. package/src/routes/session/index.ts +7 -7
  280. package/src/routes/session/refresh-viability.ts +17 -17
  281. package/src/server/auth-guard.ts +203 -203
  282. package/src/server/auth.ts +486 -486
  283. package/src/server/decode-session.ts +309 -309
  284. package/src/server/slim-middleware.ts +117 -117
  285. package/src/server/verified-session-token.ts +103 -103
  286. package/src/server/with-auth.ts +93 -93
  287. package/src/services/signalrActivityService.ts +258 -258
  288. package/src/stores/authStore.ts +1900 -1900
  289. package/src/templates/instrumentation.ts +60 -60
  290. package/src/theme/ThemeProvider.tsx +39 -39
  291. package/src/theme/default.ts +33 -33
  292. package/src/theme/index.ts +31 -31
  293. package/src/theme/types.ts +69 -69
  294. package/src/theme/useTheme.ts +57 -57
  295. package/src/theme/utils.ts +40 -40
  296. package/src/types/api.ts +12 -12
  297. package/src/types/auth.d.ts +23 -23
  298. package/src/types/auth.ts +48 -48
  299. package/src/types/logging.ts +11 -11
  300. package/src/types/recovery.ts +54 -54
  301. package/src/utils/api.ts +401 -401
  302. package/src/utils/circuitBreaker.ts +40 -40
  303. package/src/utils/error-message.ts +108 -108
  304. package/src/utils/layout/reservedSpace.ts +124 -124
  305. package/src/utils/logout.ts +30 -30
  306. package/src/vibe/client.ts +878 -877
  307. package/src/vibe/enterprise-auth.ts +208 -208
  308. package/src/vibe/errors.ts +209 -209
  309. package/src/vibe/filters.ts +52 -0
  310. package/src/vibe/generic.ts +340 -339
  311. package/src/vibe/hooks/index.ts +367 -367
  312. package/src/vibe/index.ts +133 -133
  313. package/src/vibe/sessions.ts +925 -899
  314. package/src/vibe/types.ts +577 -577
package/src/lib/roles.ts CHANGED
@@ -1,177 +1,177 @@
1
- /**
2
- * Vibe Role Constants and Utilities
3
- *
4
- * Centralized role definitions for consistent authorization across the stack.
5
- *
6
- * @version 1.0
7
- */
8
-
9
- // =============================================================================
10
- // Role Constants
11
- // =============================================================================
12
-
13
- /**
14
- * Global platform roles (IDP-level)
15
- * These roles are managed at the IDP and grant cross-client access.
16
- */
17
- export const GlobalRoles = {
18
- /** Platform super admin - full access to everything */
19
- PAYEZ_ADMIN: 'payez_admin',
20
-
21
- /** IDP client admin - manages IDP client configuration */
22
- IDP_CLIENT_ADMIN: 'idp_client_admin',
23
-
24
- /** Vibe platform admin - manages Vibe infrastructure globally */
25
- VIBE_APP_ADMIN: 'vibe_app_admin',
26
-
27
- /** Vibe client admin - manages Vibe for a specific tenant */
28
- VIBE_CLIENT_ADMIN: 'vibe_client_admin',
29
-
30
- /** Vibe agents user - AI agents operating via CLI/automation */
31
- VIBE_AGENTS_USER: 'vibe_agents_user',
32
- } as const;
33
-
34
- /**
35
- * Application-level roles (per-client)
36
- * These roles are scoped to a specific client/tenant.
37
- */
38
- export const AppRoles = {
39
- /** Standard authenticated user */
40
- VIBE_APP_USER: 'vibe_app_user',
41
- } as const;
42
-
43
- /**
44
- * All Vibe roles combined
45
- */
46
- export const VibeRoles = {
47
- ...GlobalRoles,
48
- ...AppRoles,
49
- } as const;
50
-
51
- // =============================================================================
52
- // Role Groups
53
- // =============================================================================
54
-
55
- /**
56
- * Roles that grant admin access to the /admin section.
57
- * Any of these roles allows access to admin pages.
58
- */
59
- export const ADMIN_ROLES: readonly string[] = [
60
- GlobalRoles.PAYEZ_ADMIN,
61
- GlobalRoles.VIBE_APP_ADMIN,
62
- GlobalRoles.VIBE_CLIENT_ADMIN,
63
- GlobalRoles.IDP_CLIENT_ADMIN,
64
- ] as const;
65
-
66
- /**
67
- * Roles that grant platform-wide admin access (not client-scoped).
68
- * These can access/modify any client's data.
69
- */
70
- export const PLATFORM_ADMIN_ROLES: readonly string[] = [
71
- GlobalRoles.PAYEZ_ADMIN,
72
- GlobalRoles.VIBE_APP_ADMIN,
73
- ] as const;
74
-
75
- /**
76
- * Roles that grant client-scoped admin access.
77
- * These can only access their own client's data.
78
- */
79
- export const CLIENT_ADMIN_ROLES: readonly string[] = [
80
- GlobalRoles.VIBE_CLIENT_ADMIN,
81
- GlobalRoles.IDP_CLIENT_ADMIN,
82
- ] as const;
83
-
84
- // =============================================================================
85
- // Role Checking Utilities
86
- // =============================================================================
87
-
88
- /**
89
- * Check if user has a specific role
90
- */
91
- export function hasRole(userRoles: string[] | undefined, role: string): boolean {
92
- if (!userRoles || !Array.isArray(userRoles)) return false;
93
- return userRoles.includes(role);
94
- }
95
-
96
- /**
97
- * Check if user has any of the specified roles
98
- */
99
- export function hasAnyRole(userRoles: string[] | undefined, roles: readonly string[]): boolean {
100
- if (!userRoles || !Array.isArray(userRoles)) return false;
101
- return roles.some(role => userRoles.includes(role));
102
- }
103
-
104
- /**
105
- * Check if user has all of the specified roles
106
- */
107
- export function hasAllRoles(userRoles: string[] | undefined, roles: readonly string[]): boolean {
108
- if (!userRoles || !Array.isArray(userRoles)) return false;
109
- return roles.every(role => userRoles.includes(role));
110
- }
111
-
112
- /**
113
- * Check if user has admin access (any admin role)
114
- */
115
- export function isAdmin(userRoles: string[] | undefined): boolean {
116
- return hasAnyRole(userRoles, ADMIN_ROLES);
117
- }
118
-
119
- /**
120
- * Check if user has platform-wide admin access
121
- */
122
- export function isPlatformAdmin(userRoles: string[] | undefined): boolean {
123
- return hasAnyRole(userRoles, PLATFORM_ADMIN_ROLES);
124
- }
125
-
126
- /**
127
- * Check if user is a client-scoped admin (not platform admin)
128
- */
129
- export function isClientAdmin(userRoles: string[] | undefined): boolean {
130
- return hasAnyRole(userRoles, CLIENT_ADMIN_ROLES) && !isPlatformAdmin(userRoles);
131
- }
132
-
133
- // =============================================================================
134
- // Role Hierarchy
135
- // =============================================================================
136
-
137
- /**
138
- * Role hierarchy (higher index = more access)
139
- *
140
- * payez_admin (4) - IDP super admin, can do anything
141
- * vibe_app_admin (3) - Platform admin, manages Vibe globally
142
- * vibe_client_admin (2) - Client admin, manages their own tenant
143
- * idp_client_admin (2) - IDP client admin, manages IDP config
144
- * vibe_app_user (1) - Regular authenticated user
145
- * (anonymous) (0) - No authentication
146
- */
147
- export const ROLE_HIERARCHY: Record<string, number> = {
148
- [GlobalRoles.PAYEZ_ADMIN]: 4,
149
- [GlobalRoles.VIBE_APP_ADMIN]: 3,
150
- [GlobalRoles.VIBE_CLIENT_ADMIN]: 2,
151
- [GlobalRoles.IDP_CLIENT_ADMIN]: 2,
152
- [AppRoles.VIBE_APP_USER]: 1,
153
- };
154
-
155
- /**
156
- * Get the highest role level for a user
157
- */
158
- export function getHighestRoleLevel(userRoles: string[] | undefined): number {
159
- if (!userRoles || !Array.isArray(userRoles)) return 0;
160
- return Math.max(0, ...userRoles.map(role => ROLE_HIERARCHY[role] || 0));
161
- }
162
-
163
- export default {
164
- VibeRoles,
165
- GlobalRoles,
166
- AppRoles,
167
- ADMIN_ROLES,
168
- PLATFORM_ADMIN_ROLES,
169
- CLIENT_ADMIN_ROLES,
170
- hasRole,
171
- hasAnyRole,
172
- hasAllRoles,
173
- isAdmin,
174
- isPlatformAdmin,
175
- isClientAdmin,
176
- getHighestRoleLevel,
177
- };
1
+ /**
2
+ * Vibe Role Constants and Utilities
3
+ *
4
+ * Centralized role definitions for consistent authorization across the stack.
5
+ *
6
+ * @version 1.0
7
+ */
8
+
9
+ // =============================================================================
10
+ // Role Constants
11
+ // =============================================================================
12
+
13
+ /**
14
+ * Global platform roles (IDP-level)
15
+ * These roles are managed at the IDP and grant cross-client access.
16
+ */
17
+ export const GlobalRoles = {
18
+ /** Platform super admin - full access to everything */
19
+ PAYEZ_ADMIN: 'payez_admin',
20
+
21
+ /** IDP client admin - manages IDP client configuration */
22
+ IDP_CLIENT_ADMIN: 'idp_client_admin',
23
+
24
+ /** Vibe platform admin - manages Vibe infrastructure globally */
25
+ VIBE_APP_ADMIN: 'vibe_app_admin',
26
+
27
+ /** Vibe client admin - manages Vibe for a specific tenant */
28
+ VIBE_CLIENT_ADMIN: 'vibe_client_admin',
29
+
30
+ /** Vibe agents user - AI agents operating via CLI/automation */
31
+ VIBE_AGENTS_USER: 'vibe_agents_user',
32
+ } as const;
33
+
34
+ /**
35
+ * Application-level roles (per-client)
36
+ * These roles are scoped to a specific client/tenant.
37
+ */
38
+ export const AppRoles = {
39
+ /** Standard authenticated user */
40
+ VIBE_APP_USER: 'vibe_app_user',
41
+ } as const;
42
+
43
+ /**
44
+ * All Vibe roles combined
45
+ */
46
+ export const VibeRoles = {
47
+ ...GlobalRoles,
48
+ ...AppRoles,
49
+ } as const;
50
+
51
+ // =============================================================================
52
+ // Role Groups
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Roles that grant admin access to the /admin section.
57
+ * Any of these roles allows access to admin pages.
58
+ */
59
+ export const ADMIN_ROLES: readonly string[] = [
60
+ GlobalRoles.PAYEZ_ADMIN,
61
+ GlobalRoles.VIBE_APP_ADMIN,
62
+ GlobalRoles.VIBE_CLIENT_ADMIN,
63
+ GlobalRoles.IDP_CLIENT_ADMIN,
64
+ ] as const;
65
+
66
+ /**
67
+ * Roles that grant platform-wide admin access (not client-scoped).
68
+ * These can access/modify any client's data.
69
+ */
70
+ export const PLATFORM_ADMIN_ROLES: readonly string[] = [
71
+ GlobalRoles.PAYEZ_ADMIN,
72
+ GlobalRoles.VIBE_APP_ADMIN,
73
+ ] as const;
74
+
75
+ /**
76
+ * Roles that grant client-scoped admin access.
77
+ * These can only access their own client's data.
78
+ */
79
+ export const CLIENT_ADMIN_ROLES: readonly string[] = [
80
+ GlobalRoles.VIBE_CLIENT_ADMIN,
81
+ GlobalRoles.IDP_CLIENT_ADMIN,
82
+ ] as const;
83
+
84
+ // =============================================================================
85
+ // Role Checking Utilities
86
+ // =============================================================================
87
+
88
+ /**
89
+ * Check if user has a specific role
90
+ */
91
+ export function hasRole(userRoles: string[] | undefined, role: string): boolean {
92
+ if (!userRoles || !Array.isArray(userRoles)) return false;
93
+ return userRoles.includes(role);
94
+ }
95
+
96
+ /**
97
+ * Check if user has any of the specified roles
98
+ */
99
+ export function hasAnyRole(userRoles: string[] | undefined, roles: readonly string[]): boolean {
100
+ if (!userRoles || !Array.isArray(userRoles)) return false;
101
+ return roles.some(role => userRoles.includes(role));
102
+ }
103
+
104
+ /**
105
+ * Check if user has all of the specified roles
106
+ */
107
+ export function hasAllRoles(userRoles: string[] | undefined, roles: readonly string[]): boolean {
108
+ if (!userRoles || !Array.isArray(userRoles)) return false;
109
+ return roles.every(role => userRoles.includes(role));
110
+ }
111
+
112
+ /**
113
+ * Check if user has admin access (any admin role)
114
+ */
115
+ export function isAdmin(userRoles: string[] | undefined): boolean {
116
+ return hasAnyRole(userRoles, ADMIN_ROLES);
117
+ }
118
+
119
+ /**
120
+ * Check if user has platform-wide admin access
121
+ */
122
+ export function isPlatformAdmin(userRoles: string[] | undefined): boolean {
123
+ return hasAnyRole(userRoles, PLATFORM_ADMIN_ROLES);
124
+ }
125
+
126
+ /**
127
+ * Check if user is a client-scoped admin (not platform admin)
128
+ */
129
+ export function isClientAdmin(userRoles: string[] | undefined): boolean {
130
+ return hasAnyRole(userRoles, CLIENT_ADMIN_ROLES) && !isPlatformAdmin(userRoles);
131
+ }
132
+
133
+ // =============================================================================
134
+ // Role Hierarchy
135
+ // =============================================================================
136
+
137
+ /**
138
+ * Role hierarchy (higher index = more access)
139
+ *
140
+ * payez_admin (4) - IDP super admin, can do anything
141
+ * vibe_app_admin (3) - Platform admin, manages Vibe globally
142
+ * vibe_client_admin (2) - Client admin, manages their own tenant
143
+ * idp_client_admin (2) - IDP client admin, manages IDP config
144
+ * vibe_app_user (1) - Regular authenticated user
145
+ * (anonymous) (0) - No authentication
146
+ */
147
+ export const ROLE_HIERARCHY: Record<string, number> = {
148
+ [GlobalRoles.PAYEZ_ADMIN]: 4,
149
+ [GlobalRoles.VIBE_APP_ADMIN]: 3,
150
+ [GlobalRoles.VIBE_CLIENT_ADMIN]: 2,
151
+ [GlobalRoles.IDP_CLIENT_ADMIN]: 2,
152
+ [AppRoles.VIBE_APP_USER]: 1,
153
+ };
154
+
155
+ /**
156
+ * Get the highest role level for a user
157
+ */
158
+ export function getHighestRoleLevel(userRoles: string[] | undefined): number {
159
+ if (!userRoles || !Array.isArray(userRoles)) return 0;
160
+ return Math.max(0, ...userRoles.map(role => ROLE_HIERARCHY[role] || 0));
161
+ }
162
+
163
+ export default {
164
+ VibeRoles,
165
+ GlobalRoles,
166
+ AppRoles,
167
+ ADMIN_ROLES,
168
+ PLATFORM_ADMIN_ROLES,
169
+ CLIENT_ADMIN_ROLES,
170
+ hasRole,
171
+ hasAnyRole,
172
+ hasAllRoles,
173
+ isAdmin,
174
+ isPlatformAdmin,
175
+ isClientAdmin,
176
+ getHighestRoleLevel,
177
+ };
@@ -1,149 +1,149 @@
1
- /**
2
- * PAY-1438: what a USER-FACING handler may put in an error response.
3
- *
4
- * Several of these routes are reachable before authentication (session check, viability probe,
5
- * logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
6
- *
7
- * Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
8
- * JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
9
- * a code and a correlationId; the log line carries the exception and the same id.
10
- *
11
- * Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
12
- * never passed through whole. Only the display contract is projected: code, message, resolution,
13
- * request_id - each only when it is a string. `details` (free-form, may carry validation internals
14
- * or exception data) is dropped, except for the password-policy messages change-password's UI reads.
15
- */
16
-
17
- import { knownPasswordRules } from './password-rules';
18
-
19
- export interface IdpErrorProjection {
20
- code?: string;
21
- message?: string;
22
- resolution?: string;
23
- request_id?: string;
24
- }
25
-
26
- const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
27
-
28
- /** Log an exception with a fresh correlationId and return the id for the response body. */
29
- export function logExceptionWithCorrelation(tag: string, err: unknown): string {
30
- const correlationId = globalThis.crypto.randomUUID();
31
- console.error(tag, { correlationId, error: err });
32
- return correlationId;
33
- }
34
-
35
- /** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
36
- export function projectIdpError(json: unknown): IdpErrorProjection {
37
- if (!json || typeof json !== 'object') return {};
38
- const top = json as Record<string, unknown>;
39
- const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
40
- const support = err.support && typeof err.support === 'object' ? (err.support as Record<string, unknown>) : {};
41
- const out: IdpErrorProjection = {};
42
- const code = str(err.code) ?? str(top.code) ?? str(top.error_code);
43
- const message = str(err.message) ?? str(top.message);
44
- const resolution = str(err.resolution) ?? str(top.resolution);
45
- const requestId = str(support.request_id) ?? str(top.request_id);
46
- if (code !== undefined) out.code = code;
47
- if (message !== undefined) out.message = message;
48
- if (resolution !== undefined) out.resolution = resolution;
49
- if (requestId !== undefined) out.request_id = requestId;
50
- return out;
51
- }
52
-
53
- /**
54
- * change-password's `details`: the security pages show `details.value[0].message` (else
55
- * `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
56
- * strings; drop everything else.
57
- */
58
- export function projectPasswordPolicyDetails(details: unknown): { message?: string; value?: Array<{ message: string }> } | undefined {
59
- if (!details || typeof details !== 'object') return undefined;
60
- const d = details as Record<string, unknown>;
61
- const out: { message?: string; value?: Array<{ message: string }> } = {};
62
- if (Array.isArray(d.value)) {
63
- const value = d.value
64
- .map((v) => (v && typeof v === 'object' ? str((v as Record<string, unknown>).message) : undefined))
65
- .filter((m): m is string => m !== undefined)
66
- .map((message) => ({ message }));
67
- if (value.length) out.value = value;
68
- }
69
- const message = str(d.message);
70
- if (message !== undefined) out.message = message;
71
- return out.value || out.message !== undefined ? out : undefined;
72
- }
73
-
74
- /**
75
- * For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
76
- * reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
77
- * SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
78
- * whitelisted projection. The success path keeps its passthrough.
79
- */
80
- export function idpErrorEnvelope(json: unknown, fallbackMessage = 'Request failed'): {
81
- success: false;
82
- message: string;
83
- error: { code?: string; message: string; resolution?: string };
84
- request_id?: string;
85
- } {
86
- const p = projectIdpError(json);
87
- const message = p.message ?? fallbackMessage;
88
- const error: { code?: string; message: string; resolution?: string } = { message };
89
- if (p.code !== undefined) error.code = p.code;
90
- if (p.resolution !== undefined) error.resolution = p.resolution;
91
- const out: { success: false; message: string; error: typeof error; request_id?: string } = { success: false, message, error };
92
- if (p.request_id !== undefined) out.request_id = p.request_id;
93
- return out;
94
- }
95
-
96
- /**
97
- * True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
98
- * ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
99
- * expired token, AccountController.ResetPassword line ~699 at b5c56e6c0). Only this boolean crosses the anonymous route, never the
100
- * Identity error text. Anything else is NOT a dead-link signal (rigpert 60822): an older IdP sends a weak-password refusal with
101
- * no code at all, and calling that 'link expired' loops the user.
102
- */
103
- export function projectResetTokenInvalid(json: unknown): boolean {
104
- if (!json || typeof json !== 'object') return false;
105
- const top = json as Record<string, unknown>;
106
- const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
107
- const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
108
- if (!details) return false;
109
- if (details.code === 'token_expired' || details.code === 'token_used') return true;
110
- return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && (v as Record<string, unknown>).code === 'InvalidToken');
111
- }
112
-
113
- /**
114
- * PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
115
- * [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
116
- * unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
117
- * (the caller must be able to tell a refused password from a dead recovery session).
118
- */
119
- export function projectPasswordPolicyRules(json: unknown): string[] | undefined {
120
- if (!json || typeof json !== 'object') return undefined;
121
- const top = json as Record<string, unknown>;
122
- const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
123
- const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
124
- if (!details || details.code !== 'password_policy') return undefined;
125
- return knownPasswordRules(details.rules);
126
- }
127
-
128
- /**
129
- * PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
130
- * the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
131
- * or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
132
- * (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
133
- * only the code, never IdP text.
134
- */
135
- export function projectPasswordBreached(json: unknown): boolean {
136
- if (!json || typeof json !== 'object') return false;
137
- const top = json as Record<string, unknown>;
138
- const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
139
- const direct = str(err.code) ?? str(top.code) ?? str(top.error_code);
140
- if (direct?.toLowerCase() === 'password_breached') return true;
141
- const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
142
- if (str(details?.code)?.toLowerCase() === 'password_breached') return true;
143
- // THE SHAPE THE IdP ACTUALLY SENDS (DotNetPert-Scout, PAY-2103 PR 850caa07a): error.details =
144
- // { field:'new_password', code:'password_policy', rules:['password_breached'], requirements:[...] }.
145
- // The breach is a member of rules[], not a code - the house envelope carries every password rule
146
- // that way. Reading only a code would MISS the real refusal.
147
- if (Array.isArray(details?.rules) && (details!.rules as unknown[]).some((r) => typeof r === 'string' && r.toLowerCase() === 'password_breached')) return true;
148
- return Array.isArray(details?.value) && (details!.value as unknown[]).some((v) => !!v && typeof v === 'object' && str((v as Record<string, unknown>).code)?.toLowerCase() === 'password_breached');
149
- }
1
+ /**
2
+ * PAY-1438: what a USER-FACING handler may put in an error response.
3
+ *
4
+ * Several of these routes are reachable before authentication (session check, viability probe,
5
+ * logout, auth status, refresh-viability), so whatever lands in the body reaches an anonymous caller.
6
+ *
7
+ * Class A - an EXCEPTION: its text (ioredis `connect ECONNREFUSED host:port`, undici `fetch failed`,
8
+ * JSON parse errors quoting payload bytes) stays server-side. The response carries a fixed message,
9
+ * a code and a correlationId; the log line carries the exception and the same id.
10
+ *
11
+ * Class B - an IDP ERROR ENVELOPE ({ success, message, error: { code, message, details, support } }):
12
+ * never passed through whole. Only the display contract is projected: code, message, resolution,
13
+ * request_id - each only when it is a string. `details` (free-form, may carry validation internals
14
+ * or exception data) is dropped, except for the password-policy messages change-password's UI reads.
15
+ */
16
+
17
+ import { knownPasswordRules } from './password-rules';
18
+
19
+ export interface IdpErrorProjection {
20
+ code?: string;
21
+ message?: string;
22
+ resolution?: string;
23
+ request_id?: string;
24
+ }
25
+
26
+ const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
27
+
28
+ /** Log an exception with a fresh correlationId and return the id for the response body. */
29
+ export function logExceptionWithCorrelation(tag: string, err: unknown): string {
30
+ const correlationId = globalThis.crypto.randomUUID();
31
+ console.error(tag, { correlationId, error: err });
32
+ return correlationId;
33
+ }
34
+
35
+ /** Whitelisted projection of an IDP error envelope: code / message / resolution / request_id only. */
36
+ export function projectIdpError(json: unknown): IdpErrorProjection {
37
+ if (!json || typeof json !== 'object') return {};
38
+ const top = json as Record<string, unknown>;
39
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
40
+ const support = err.support && typeof err.support === 'object' ? (err.support as Record<string, unknown>) : {};
41
+ const out: IdpErrorProjection = {};
42
+ const code = str(err.code) ?? str(top.code) ?? str(top.error_code);
43
+ const message = str(err.message) ?? str(top.message);
44
+ const resolution = str(err.resolution) ?? str(top.resolution);
45
+ const requestId = str(support.request_id) ?? str(top.request_id);
46
+ if (code !== undefined) out.code = code;
47
+ if (message !== undefined) out.message = message;
48
+ if (resolution !== undefined) out.resolution = resolution;
49
+ if (requestId !== undefined) out.request_id = requestId;
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * change-password's `details`: the security pages show `details.value[0].message` (else
55
+ * `details.message`) as the password-policy error (QAPert census on PAY-1438). Keep exactly those
56
+ * strings; drop everything else.
57
+ */
58
+ export function projectPasswordPolicyDetails(details: unknown): { message?: string; value?: Array<{ message: string }> } | undefined {
59
+ if (!details || typeof details !== 'object') return undefined;
60
+ const d = details as Record<string, unknown>;
61
+ const out: { message?: string; value?: Array<{ message: string }> } = {};
62
+ if (Array.isArray(d.value)) {
63
+ const value = d.value
64
+ .map((v) => (v && typeof v === 'object' ? str((v as Record<string, unknown>).message) : undefined))
65
+ .filter((m): m is string => m !== undefined)
66
+ .map((message) => ({ message }));
67
+ if (value.length) out.value = value;
68
+ }
69
+ const message = str(d.message);
70
+ if (message !== undefined) out.message = message;
71
+ return out.value || out.message !== undefined ? out : undefined;
72
+ }
73
+
74
+ /**
75
+ * For proxies that used to pass the IDP response through WHOLE on every status (account recovery,
76
+ * reset-password, passkey sign-in - all anonymous): on a non-OK status, return the same envelope
77
+ * SHAPE callers already read (`message`, `error.code`, `error.message`) built only from the
78
+ * whitelisted projection. The success path keeps its passthrough.
79
+ */
80
+ export function idpErrorEnvelope(json: unknown, fallbackMessage = 'Request failed'): {
81
+ success: false;
82
+ message: string;
83
+ error: { code?: string; message: string; resolution?: string };
84
+ request_id?: string;
85
+ } {
86
+ const p = projectIdpError(json);
87
+ const message = p.message ?? fallbackMessage;
88
+ const error: { code?: string; message: string; resolution?: string } = { message };
89
+ if (p.code !== undefined) error.code = p.code;
90
+ if (p.resolution !== undefined) error.resolution = p.resolution;
91
+ const out: { success: false; message: string; error: typeof error; request_id?: string } = { success: false, message, error };
92
+ if (p.request_id !== undefined) out.request_id = p.request_id;
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * True only when the IdP EXPLICITLY says the recovery token itself is unusable: details.code token_expired / token_used, or an
98
+ * ASP.NET Identity `InvalidToken` error in details.value (what the served IdP sends when ResetPasswordAsync refuses a used or
99
+ * expired token, AccountController.ResetPassword line ~699 at b5c56e6c0). Only this boolean crosses the anonymous route, never the
100
+ * Identity error text. Anything else is NOT a dead-link signal (rigpert 60822): an older IdP sends a weak-password refusal with
101
+ * no code at all, and calling that 'link expired' loops the user.
102
+ */
103
+ export function projectResetTokenInvalid(json: unknown): boolean {
104
+ if (!json || typeof json !== 'object') return false;
105
+ const top = json as Record<string, unknown>;
106
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
107
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
108
+ if (!details) return false;
109
+ if (details.code === 'token_expired' || details.code === 'token_used') return true;
110
+ return Array.isArray(details.value) && details.value.some((v) => !!v && typeof v === 'object' && (v as Record<string, unknown>).code === 'InvalidToken');
111
+ }
112
+
113
+ /**
114
+ * PAY-1591: the IdP refuses a reset password with error.details = { code: 'password_policy', rules: [codes], requirements:
115
+ * [English] }. This route is anonymous, so only the CODES cross, and only the known ones: never `requirements`, never an
116
+ * unknown string. Returns undefined when this is NOT a password-policy refusal, and [] when it is one but names no rule we know
117
+ * (the caller must be able to tell a refused password from a dead recovery session).
118
+ */
119
+ export function projectPasswordPolicyRules(json: unknown): string[] | undefined {
120
+ if (!json || typeof json !== 'object') return undefined;
121
+ const top = json as Record<string, unknown>;
122
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
123
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
124
+ if (!details || details.code !== 'password_policy') return undefined;
125
+ return knownPasswordRules(details.rules);
126
+ }
127
+
128
+ /**
129
+ * PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
130
+ * the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
131
+ * or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
132
+ * (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
133
+ * only the code, never IdP text.
134
+ */
135
+ export function projectPasswordBreached(json: unknown): boolean {
136
+ if (!json || typeof json !== 'object') return false;
137
+ const top = json as Record<string, unknown>;
138
+ const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
139
+ const direct = str(err.code) ?? str(top.code) ?? str(top.error_code);
140
+ if (direct?.toLowerCase() === 'password_breached') return true;
141
+ const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
142
+ if (str(details?.code)?.toLowerCase() === 'password_breached') return true;
143
+ // THE SHAPE THE IdP ACTUALLY SENDS (DotNetPert-Scout, PAY-2103 PR 850caa07a): error.details =
144
+ // { field:'new_password', code:'password_policy', rules:['password_breached'], requirements:[...] }.
145
+ // The breach is a member of rules[], not a code - the house envelope carries every password rule
146
+ // that way. Reading only a code would MISS the real refusal.
147
+ if (Array.isArray(details?.rules) && (details!.rules as unknown[]).some((r) => typeof r === 'string' && r.toLowerCase() === 'password_breached')) return true;
148
+ return Array.isArray(details?.value) && (details!.value as unknown[]).some((v) => !!v && typeof v === 'object' && str((v as Record<string, unknown>).code)?.toLowerCase() === 'password_breached');
149
+ }