@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
@@ -1,902 +1,902 @@
1
- "use client";
2
-
3
- // ========================================================================================
4
- // BULLETPROOF STANDARDIZED CLIENT API - ZERO TOLERANCE FOR BAD RESPONSES
5
- // ========================================================================================
6
- // This client API ENFORCES the standardized response format
7
- // It will BREAK if APIs don't return the expected structure
8
- // NO MORE GUESSING data.data.data.data - EVER AGAIN!
9
- // ========================================================================================
10
-
11
- import { authClient } from '../client/better-auth-client';
12
-
13
- /** Unwrap Better Auth getSession() to a flat session-like object for backward compat */
14
- async function getSession(): Promise<any> {
15
- const { data } = await authClient.getSession();
16
- return data ?? null;
17
- }
18
-
19
- import {
20
- StandardizedResponse,
21
- StandardizedApiResponse,
22
- StandardizedPagedResponse,
23
- StandardizedErrorResponse,
24
- validateStandardizedResponse,
25
- isSuccessResponse,
26
- isPagedResponse,
27
- isErrorResponse
28
- } from './types/api-responses';
29
-
30
-
31
-
32
- // ========================================================================================
33
- // CLIENT API ERROR TYPES
34
- // ========================================================================================
35
-
36
- /**
37
- * ERROR THROWN WHEN API RESPONSE FORMAT IS INVALID
38
- * This means the API is NOT following our standardized format
39
- */
40
- export class ApiResponseFormatError extends Error {
41
- constructor(message: string, public readonly endpoint: string, public readonly rawResponse: unknown) {
42
- super(`API_FORMAT_ERROR: ${message}`);
43
- this.name = 'ApiResponseFormatError';
44
- }
45
- }
46
-
47
- /**
48
- * ERROR THROWN WHEN API RETURNS A STANDARDIZED ERROR RESPONSE
49
- * This is a properly formatted error from the API
50
- */
51
- export class ApiBusinessLogicError extends Error {
52
- constructor(
53
- public readonly errorCode: string,
54
- message: string,
55
- public readonly operation: string,
56
- public readonly details?: unknown
57
- ) {
58
- super(message);
59
- this.name = 'ApiBusinessLogicError';
60
- }
61
- }
62
-
63
- /**
64
- * ERROR THROWN WHEN VALIDATION FAILS
65
- * This is a properly formatted validation error from the API
66
- */
67
- export class ApiValidationError extends Error {
68
- constructor(
69
- message: string,
70
- public readonly operation: string,
71
- public readonly validationErrors: Record<string, string[]>,
72
- public readonly invalidValue?: unknown,
73
- public readonly primaryField?: string
74
- ) {
75
- super(message);
76
- this.name = 'ApiValidationError';
77
- }
78
- }
79
-
80
- /**
81
- * ERROR THROWN WHEN NETWORK/HTTP ISSUES OCCUR
82
- */
83
- export class ApiNetworkError extends Error {
84
- constructor(message: string, public readonly status: number, public readonly endpoint: string) {
85
- super(`NETWORK_ERROR: ${message}`);
86
- this.name = 'ApiNetworkError';
87
- }
88
- }
89
-
90
- // ========================================================================================
91
- // AUTHENTICATION STATE MANAGEMENT
92
- // ========================================================================================
93
-
94
- // Coordinate client-side refresh to avoid duplicate refresh calls racing with
95
- // server-side middleware or other tabs. Only one refresh runs at a time.
96
- let refreshInFlight: Promise<boolean> | null = null;
97
-
98
- // Enhanced redirect logic with grace period and retry attempts
99
- let authRedirectScheduled = false;
100
- let lastAuthFailureTime = 0;
101
- let consecutiveAuthFailures = 0;
102
- const AUTH_FAILURE_GRACE_PERIOD = 2000; // 2 seconds grace period
103
- const MAX_AUTH_FAILURES_BEFORE_REDIRECT = 2; // Allow 2 failures before redirect
104
- const AUTH_FAILURE_RESET_WINDOW = 30000; // Reset failure count after 30 seconds
105
-
106
- // Helper: detect pre-2FA session (session exists, requires 2FA and not completed)
107
- function isPreTwoFactorSession(session: any | null | undefined): boolean {
108
- return !!(session?.user?.requiresTwoFactor && !session?.user?.twoFactorSessionVerified);
109
- }
110
-
111
- // Reset auth failure state on successful requests
112
- function resetAuthFailureState() {
113
- if (consecutiveAuthFailures > 0) {
114
- console.log(`✅ Resetting auth failure state (was ${consecutiveAuthFailures} failures)`);
115
- consecutiveAuthFailures = 0;
116
- lastAuthFailureTime = 0;
117
- authRedirectScheduled = false;
118
- }
119
- }
120
-
121
- function scheduleLoginRedirect(isImmediate = false) {
122
- if (authRedirectScheduled) return;
123
-
124
- const now = Date.now();
125
-
126
- // Reset consecutive failures if enough time has passed
127
- if (now - lastAuthFailureTime > AUTH_FAILURE_RESET_WINDOW) {
128
- consecutiveAuthFailures = 0;
129
- }
130
-
131
- consecutiveAuthFailures++;
132
- lastAuthFailureTime = now;
133
-
134
- console.warn(`🔴 Auth failure #${consecutiveAuthFailures}, immediate: ${isImmediate}`);
135
-
136
- // Only redirect if we've had multiple failures or if explicitly requested
137
- if (!isImmediate && consecutiveAuthFailures < MAX_AUTH_FAILURES_BEFORE_REDIRECT) {
138
- console.log(`⏳ Delaying redirect - only ${consecutiveAuthFailures} failures so far`);
139
- return;
140
- }
141
-
142
- authRedirectScheduled = true;
143
-
144
- const redirectFunction = () => {
145
- try {
146
- const returnUrl = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
147
- console.warn(`🔄 Redirecting to login with return URL: ${returnUrl}`);
148
- window.location.href = `/account-auth/login?returnUrl=${returnUrl}`;
149
- } catch (error) {
150
- console.error('❌ Error during login redirect:', error);
151
- // Final fallback
152
- try {
153
- window.location.href = '/account-auth/login';
154
- } catch {
155
- // no-op if window is not available
156
- }
157
- }
158
- };
159
-
160
- if (isImmediate) {
161
- // Immediate redirect for critical auth failures
162
- redirectFunction();
163
- } else {
164
- // Small delay to allow any pending requests to complete
165
- console.log(`⏳ Scheduling redirect with ${AUTH_FAILURE_GRACE_PERIOD}ms grace period`);
166
- setTimeout(redirectFunction, AUTH_FAILURE_GRACE_PERIOD);
167
- }
168
- }
169
-
170
- // ========================================================================================
171
- // RESULT TYPES FOR CLIENT CONSUMPTION
172
- // ========================================================================================
173
-
174
- /**
175
- * SUCCESSFUL API CALL RESULT
176
- * This is what gets returned to the calling code for successful operations
177
- */
178
- export interface ApiSuccessResult<TData> {
179
- /** Always true for success */
180
- success: true;
181
- /** The actual data - NO NESTING! Direct access! */
182
- data: TData;
183
- /** Human-readable success message from API */
184
- message: string;
185
- /** Operation code for tracking/debugging */
186
- operation_code: string;
187
- /** Server timestamp (if provided) */
188
- timestamp?: string;
189
- }
190
-
191
- /**
192
- * SUCCESSFUL PAGED API CALL RESULT
193
- * This is what gets returned for successful paged operations
194
- */
195
- export interface ApiPagedResult<TData> {
196
- /** Always true for success */
197
- success: true;
198
- /** The actual data array - NO NESTING! Direct access! */
199
- items: TData[];
200
- /** Human-readable success message from API */
201
- message: string;
202
- /** Operation code for tracking/debugging */
203
- operation_code: string;
204
- /** Pagination information */
205
- pagination: {
206
- current_page: number;
207
- total_pages: number;
208
- page_size: number;
209
- total_items: number;
210
- has_next_page: boolean;
211
- has_previous_page: boolean;
212
- };
213
- /** Server timestamp (if provided) */
214
- timestamp?: string;
215
- }
216
-
217
- /**
218
- * FAILED API CALL RESULT
219
- * This is what gets returned to the calling code for failed operations
220
- */
221
- export interface ApiErrorResult {
222
- /** Always false for errors */
223
- success: false;
224
- /** Standardized error code */
225
- error_code: string;
226
- /** Human-readable error message */
227
- message: string;
228
- /** Operation that failed */
229
- operation: string;
230
- /** Additional error details (if any) */
231
- details?: unknown;
232
- /** Validation errors (if any) */
233
- validation_errors?: Record<string, string[]>;
234
- /** Server-provided request identifier for tracing (only set on real errors) */
235
- request_id?: string;
236
- }
237
-
238
- /** UNION TYPE FOR ALL POSSIBLE API RESULTS */
239
- export type ApiResult<TData> = ApiSuccessResult<TData> | ApiPagedResult<TData> | ApiErrorResult;
240
-
241
- // ========================================================================================
242
- // BULLETPROOF CLIENT API SERVICE
243
- // ========================================================================================
244
-
245
- class StandardizedClientApiService {
246
- private baseUrl: string;
247
-
248
- constructor() {
249
- this.baseUrl = '';
250
- }
251
-
252
- /**
253
- * MAKES HTTP REQUEST AND VALIDATES RESPONSE FORMAT
254
- * This method ENFORCES standardized response format compliance
255
- * Will throw ApiResponseFormatError if format is invalid
256
- */
257
- private async makeRequest<TData = unknown>(
258
- endpoint: string,
259
- options: RequestInit = {},
260
- sessionToken?: string
261
- ): Promise<ApiResult<TData>> {
262
- const fullEndpoint = `${this.baseUrl}${endpoint}`;
263
-
264
- try {
265
- // Use provided token or get from NextAuth session
266
- const currentSession = await getSession();
267
- let token = sessionToken || currentSession?.accessToken;
268
-
269
- // Preflight freshness check: if token is near expiry, coordinate refresh BEFORE making request
270
- const pre2FA = isPreTwoFactorSession(currentSession);
271
- const hasRefresh = !!currentSession?.refreshToken;
272
- if (!pre2FA && hasRefresh) {
273
- try {
274
- let timeLeft: number | null = null;
275
- if (currentSession?.accessTokenExpires) {
276
- timeLeft = currentSession.accessTokenExpires - Date.now();
277
- } else if (token) {
278
- // Fallback decode only if session did not include expiry
279
- const { jwtDecode } = await import('./jwt-decode-client');
280
- const decoded: any = jwtDecode(token);
281
- const expMs = decoded?.exp ? decoded.exp * 1000 : 0;
282
- timeLeft = expMs - Date.now();
283
- }
284
- // Refresh if <= 60s remaining (or already expired)
285
- if (timeLeft !== null && !Number.isNaN(timeLeft) && timeLeft <= 60000) {
286
- console.log(`⏳ Access token expiring soon (${Math.floor(timeLeft/1000)}s). Coordinating refresh before request...`);
287
- if (!refreshInFlight) {
288
- refreshInFlight = (async () => {
289
- const reqId = crypto.randomUUID();
290
- const rr = await fetch('/api/auth/refresh', {
291
- method: 'POST',
292
- credentials: 'include',
293
- headers: { 'X-Request-ID': reqId },
294
- });
295
- if (rr.ok || rr.status === 409) {
296
- // ok or in-progress; give it a beat in case of 409
297
- if (rr.status === 409) await new Promise(r => setTimeout(r, 1200));
298
- return true;
299
- }
300
- if (rr.status === 401 || rr.status === 403) {
301
- scheduleLoginRedirect();
302
- throw new ApiNetworkError('Authentication failed - unable to refresh session', rr.status, endpoint);
303
- }
304
- const et = await rr.text();
305
- throw new ApiNetworkError(et || 'Token refresh failed', rr.status, endpoint);
306
- })().finally(() => { refreshInFlight = null; });
307
- }
308
- await refreshInFlight;
309
- const newSessionAfter = await getSession();
310
- token = newSessionAfter?.accessToken || token;
311
- }
312
- } catch (preErr) {
313
- console.warn('Preflight token freshness check failed (continuing to attempt request):', preErr);
314
- }
315
- } else {
316
- // Elegantly skip preflight refresh in pre-2FA window or when no refresh token exists
317
- if (pre2FA) {
318
- console.log('⏭️ Skipping preflight refresh: 2FA not complete (no refresh allowed yet)');
319
- } else if (!hasRefresh) {
320
- console.log('⏭️ Skipping preflight refresh: no refresh token present');
321
- }
322
- }
323
-
324
- const config: RequestInit = {
325
- ...options,
326
- headers: {
327
- 'Content-Type': 'application/json',
328
- ...(token && { 'Authorization': `Bearer ${token}` }),
329
- ...options.headers,
330
- },
331
- };
332
-
333
- console.log(`🔄 API Request: ${options.method || 'GET'} ${fullEndpoint}`);
334
-
335
- const response = await fetch(fullEndpoint, config);
336
-
337
- if (!response.ok) {
338
- // Handle coordination blocking (503) with auto-retry
339
- if (response.status === 503) {
340
- console.log('🔄 Got 503 Service Unavailable, attempting auto-retry for coordination...');
341
-
342
- // Parse Retry-After header (in seconds)
343
- const retryAfterHeader = response.headers.get('Retry-After');
344
- let retryAfterSeconds = 1; // Default to 1 second
345
-
346
- if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) {
347
- retryAfterSeconds = parseInt(retryAfterHeader, 10);
348
- }
349
-
350
- const baseDelayMs = retryAfterSeconds * 1000;
351
- const maxRetries = 3;
352
-
353
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
354
- // Add jitter to prevent thundering herd
355
- const jitterMs = Math.floor(Math.random() * 300) - 150; // ±150ms jitter
356
- const exponentialBackoff = Math.pow(1.5, attempt - 1); // Mild exponential backoff
357
- const delayMs = Math.max(100, baseDelayMs * exponentialBackoff + jitterMs);
358
-
359
- console.log(`🔄 503 retry attempt ${attempt}/${maxRetries}, waiting ${delayMs}ms...`);
360
- await new Promise(resolve => setTimeout(resolve, delayMs));
361
-
362
- try {
363
- const retryResponse = await fetch(fullEndpoint, config);
364
-
365
- if (retryResponse.ok) {
366
- console.log(`✅ 503 retry attempt ${attempt} succeeded`);
367
- const rawData = await retryResponse.json();
368
- resetAuthFailureState();
369
-
370
- // COMPATIBILITY MODE: Handle both formats
371
- if (rawData && typeof rawData === 'object' && 'success' in rawData) {
372
- const validatedResponse = validateStandardizedResponse<TData>(rawData, endpoint);
373
- return this.convertToApiResult(validatedResponse);
374
- } else {
375
- // New format - raw data
376
- const wrappedResponse: ApiSuccessResult<TData> = {
377
- success: true,
378
- data: rawData as TData,
379
- message: 'Success',
380
- operation_code: 'RAW_RESPONSE',
381
- timestamp: new Date().toISOString()
382
- };
383
- return wrappedResponse;
384
- }
385
- }
386
-
387
- // If we get another 503, continue retrying
388
- if (retryResponse.status === 503) {
389
- console.log(`🔄 503 retry attempt ${attempt} got another 503, will retry...`);
390
- continue;
391
- }
392
-
393
- // If we get a different error, break and handle it normally
394
- console.log(`❌ 503 retry attempt ${attempt} got ${retryResponse.status}, stopping retries`);
395
- // Fall through to handle the retry response error
396
- const errorText = await retryResponse.text();
397
- let errorData;
398
- try {
399
- errorData = JSON.parse(errorText);
400
- if (errorData && typeof errorData === 'object' && 'success' in errorData) {
401
- // Detect PayEz canonical error envelope and map accordingly
402
- if (errorData.error && typeof errorData.error === 'object') {
403
- const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
404
- const reqIdBody = errorData?.request_id || errorData?.requestId;
405
- const errorResult: ApiErrorResult = {
406
- success: false,
407
- error_code: errorData?.error?.code || errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
408
- message: errorData?.error?.message || errorData?.message || `Request failed with status ${retryResponse.status}`,
409
- operation: endpoint,
410
- details: (errorData?.error?.details ?? errorData?.details) || undefined,
411
- ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
412
- };
413
- return errorResult;
414
- }
415
- // Otherwise attempt to validate as our standardized error shape
416
- const validatedError = validateStandardizedResponse(errorData, endpoint);
417
- return this.convertToApiResult(validatedError) as ApiResult<TData>;
418
- } else {
419
- // New/unknown error format - best-effort mapping
420
- const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
421
- const reqIdBody = errorData?.request_id || errorData?.requestId;
422
- const errorResult: ApiErrorResult = {
423
- success: false,
424
- error_code: errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
425
- message: errorData?.message || (typeof errorData?.error === 'string' ? errorData.error : errorData?.error?.message) || errorText || `Request failed with status ${retryResponse.status}`,
426
- operation: endpoint,
427
- details: (errorData?.error?.details ?? errorData?.details) || undefined,
428
- ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
429
- };
430
- return errorResult;
431
- }
432
- } catch {
433
- const reqIdHeader2 = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
434
- const errorResult: ApiErrorResult = {
435
- success: false,
436
- error_code: `HTTP_${retryResponse.status}`,
437
- message: errorText || `Request failed with status ${retryResponse.status}`,
438
- operation: endpoint,
439
- ...(reqIdHeader2 ? { request_id: reqIdHeader2 } : {})
440
- };
441
- return errorResult;
442
- }
443
-
444
- } catch (retryError) {
445
- console.log(`❌ 503 retry attempt ${attempt} failed with network error:`, retryError);
446
- if (attempt === maxRetries) {
447
- // If all retries failed with network errors, throw the original error
448
- throw new ApiNetworkError('Service temporarily unavailable after retries', 503, endpoint);
449
- }
450
- continue;
451
- }
452
- }
453
-
454
- // If we got here, all retries failed - fall through to normal error handling
455
- console.log('❌ All 503 retry attempts exhausted, treating as error');
456
- }
457
-
458
- // Handle authentication errors with a single refresh+retry
459
- if (response.status === 401) {
460
- console.log('🔑 Got 401, checking if we have a session to refresh...');
461
-
462
- // CRITICAL FIX: Check if we actually have a session before attempting refresh
463
- const currentSession = await getSession();
464
- if (!currentSession || !currentSession.accessToken) {
465
- console.log('🚫 No valid session found, redirecting to login instead of refresh');
466
- scheduleLoginRedirect(true); // Immediate redirect
467
- throw new ApiNetworkError('Authentication required - no valid session', 401, endpoint);
468
- }
469
-
470
- // Elegantly gate refresh during pre-2FA or when no refresh token exists
471
- const pre2FA_now = isPreTwoFactorSession(currentSession);
472
- const hasRefresh_now = !!currentSession?.refreshToken;
473
- if (pre2FA_now || !hasRefresh_now) {
474
- console.log('⏭️ Skipping 401-driven refresh:', {
475
- reason: pre2FA_now ? 'pre-2FA session' : 'no refresh token',
476
- requiresTwoFactor: (currentSession?.user as any)?.requiresTwoFactor,
477
- twoFactorVerified: (currentSession?.user as any)?.twoFactorSessionVerified,
478
- hasRefreshToken: hasRefresh_now
479
- });
480
- // CRITICAL: Redirect to login immediately if refresh is impossible
481
- console.log('🚫 Cannot refresh session, redirecting to login');
482
- scheduleLoginRedirect(true); // Immediate redirect
483
- throw new ApiNetworkError(
484
- pre2FA_now
485
- ? 'Two-factor authentication required'
486
- : 'Session expired - refresh token unavailable',
487
- 401,
488
- endpoint
489
- );
490
- }
491
-
492
- console.log('🔑 Valid session found, attempting token refresh...');
493
-
494
- // Try to refresh the token, but coordinate to avoid double refresh
495
- if (!refreshInFlight) {
496
- refreshInFlight = (async () => {
497
- const reqId = crypto.randomUUID();
498
- const refreshResponse = await fetch('/api/auth/refresh', {
499
- method: 'POST',
500
- credentials: 'include',
501
- headers: { 'X-Request-ID': reqId },
502
- });
503
-
504
- if (refreshResponse.ok) {
505
- console.log('✅ Token refreshed successfully (client-side coordinator)');
506
- return true;
507
- }
508
-
509
- // If refresh is already in progress server-side, wait briefly and allow retry
510
- if (refreshResponse.status === 409) {
511
- console.log('↪️ Refresh in progress server-side (409). Waiting for completion...');
512
- await new Promise(r => setTimeout(r, 1500));
513
- return true;
514
- }
515
-
516
- // For auth failures, schedule redirect; for others, throw
517
- if (refreshResponse.status === 401 || refreshResponse.status === 403) {
518
- scheduleLoginRedirect();
519
- throw new ApiNetworkError('Authentication failed - unable to refresh session', refreshResponse.status, endpoint);
520
- }
521
-
522
- const errorText = await refreshResponse.text();
523
- throw new ApiNetworkError(errorText || 'Token refresh failed', refreshResponse.status, endpoint);
524
- })().finally(() => { refreshInFlight = null; });
525
- }
526
-
527
- try {
528
- await refreshInFlight;
529
- } catch (e) {
530
- throw e; // bubble up to caller handling
531
- }
532
-
533
- console.log('🔁 Retrying original request after coordinated refresh...');
534
- // Get the new session and retry the original request
535
- const newSession = await getSession();
536
- const newToken = newSession?.accessToken;
537
- const retryConfig: RequestInit = {
538
- ...options,
539
- headers: {
540
- 'Content-Type': 'application/json',
541
- ...(newToken && { 'Authorization': `Bearer ${newToken}` }),
542
- ...options.headers,
543
- },
544
- };
545
-
546
- const retryResponse = await fetch(fullEndpoint, retryConfig);
547
-
548
- if (retryResponse.ok) {
549
- const rawData = await retryResponse.json();
550
- // Reset auth failure state on successful retry
551
- resetAuthFailureState();
552
-
553
- // COMPATIBILITY MODE: Handle both formats in retry as well
554
- if (rawData && typeof rawData === 'object' && 'success' in rawData) {
555
- const validatedResponse = validateStandardizedResponse<TData>(rawData, endpoint);
556
- return this.convertToApiResult(validatedResponse);
557
- } else {
558
- // New format - raw data
559
- console.log(`🔄 Converting raw retry response to standardized format for ${endpoint}`);
560
- const wrappedResponse: ApiSuccessResult<TData> = {
561
- success: true,
562
- data: rawData as TData,
563
- message: 'Success',
564
- operation_code: 'RAW_RESPONSE',
565
- timestamp: new Date().toISOString()
566
- };
567
- return wrappedResponse;
568
- }
569
- } else {
570
- // If retry still 401, schedule redirect
571
- if (retryResponse.status === 401) {
572
- scheduleLoginRedirect();
573
- }
574
-
575
- const errorText = await retryResponse.text();
576
- let errorData;
577
- try {
578
- errorData = JSON.parse(errorText);
579
- // Check if it has the success field (old format)
580
- if (errorData && typeof errorData === 'object' && 'success' in errorData) {
581
- // Detect PayEz canonical error envelope and map accordingly
582
- if (errorData.error && typeof errorData.error === 'object') {
583
- const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
584
- const reqIdBody = errorData?.request_id || errorData?.requestId;
585
- const errorResult: ApiErrorResult = {
586
- success: false,
587
- error_code: errorData?.error?.code || errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
588
- message: errorData?.error?.message || errorData?.message || `Request failed with status ${retryResponse.status}`,
589
- operation: endpoint,
590
- details: (errorData?.error?.details ?? errorData?.details) || undefined,
591
- ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
592
- };
593
- return errorResult;
594
- }
595
- const validatedError = validateStandardizedResponse(errorData, endpoint);
596
- return this.convertToApiResult(validatedError) as ApiResult<TData>;
597
- } else {
598
- // New format - convert raw error to standardized format
599
- const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
600
- const reqIdBody = errorData?.request_id || errorData?.requestId;
601
- const errorResult: ApiErrorResult = {
602
- success: false,
603
- error_code: errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
604
- message: errorData?.message || (typeof errorData?.error === 'string' ? errorData.error : errorData?.error?.message) || errorText || `Request failed with status ${retryResponse.status}`,
605
- operation: endpoint,
606
- details: (errorData?.error?.details ?? errorData?.details) || undefined,
607
- ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
608
- };
609
- return errorResult;
610
- }
611
- } catch {
612
- // If we can't parse the error, create a generic error response
613
- const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
614
- const errorResult: ApiErrorResult = {
615
- success: false,
616
- error_code: `HTTP_${retryResponse.status}`,
617
- message: errorText || `Request failed with status ${retryResponse.status}`,
618
- operation: endpoint,
619
- ...(reqIdHeader ? { request_id: reqIdHeader } : {})
620
- };
621
- return errorResult;
622
- }
623
- }
624
- }
625
-
626
- // Non-401 error: try to parse as standardized error response
627
- const errorText = await response.text();
628
- let errorData;
629
- try {
630
- errorData = JSON.parse(errorText);
631
- // Check if it has the success field (old format)
632
- if (errorData && typeof errorData === 'object' && 'success' in errorData) {
633
- // Detect PayEz canonical error envelope and map accordingly
634
- if (errorData.error && typeof errorData.error === 'object') {
635
- const reqIdHeader = response.headers.get('X-Request-ID') || response.headers.get('X-Correlation-ID');
636
- const reqIdBody = errorData?.request_id || errorData?.requestId;
637
- const errorResult: ApiErrorResult = {
638
- success: false,
639
- error_code: errorData?.error?.code || errorData?.error_code || errorData?.code || `HTTP_${response.status}`,
640
- message: errorData?.error?.message || errorData?.message || `Request failed with status ${response.status}`,
641
- operation: endpoint,
642
- details: (errorData?.error?.details ?? errorData?.details) || undefined,
643
- validation_errors: errorData?.validation_errors || undefined,
644
- ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
645
- };
646
- return errorResult;
647
- }
648
- const validatedError = validateStandardizedResponse(errorData, endpoint);
649
- return this.convertToApiResult(validatedError) as ApiResult<TData>;
650
- } else {
651
- // New format - convert raw error to standardized format
652
- const reqIdHeader = response.headers.get('X-Request-ID') || response.headers.get('X-Correlation-ID');
653
- const reqIdBody = errorData?.request_id || errorData?.requestId;
654
- const errorResult: ApiErrorResult = {
655
- success: false,
656
- error_code: errorData?.error_code || errorData?.code || `HTTP_${response.status}`,
657
- message: errorData?.message || (typeof errorData?.error === 'string' ? errorData.error : errorData?.error?.message) || errorText || `Request failed with status ${response.status}`,
658
- operation: endpoint,
659
- details: (errorData?.error?.details ?? errorData?.details) || undefined,
660
- validation_errors: errorData?.validation_errors || undefined,
661
- ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
662
- };
663
- return errorResult;
664
- }
665
- } catch (parseError) {
666
- // If we can't parse the error, create a generic error response
667
- const reqIdHeader = response.headers.get('X-Request-ID') || response.headers.get('X-Correlation-ID');
668
- const errorResult: ApiErrorResult = {
669
- success: false,
670
- error_code: `HTTP_${response.status}`,
671
- message: errorText || `Request failed with status ${response.status}`,
672
- operation: endpoint,
673
- details: undefined,
674
- ...(reqIdHeader ? { request_id: reqIdHeader } : {})
675
- };
676
- return errorResult;
677
- }
678
- }
679
-
680
- // SUCCESS PATH: Parse and validate response
681
- const rawData = await response.json();
682
-
683
- // Reset auth failure state on successful request
684
- resetAuthFailureState();
685
-
686
- // COMPATIBILITY MODE: Handle both old envelope format and new raw format
687
- try {
688
- // First check if it's the old standardized format with success field
689
- if (rawData && typeof rawData === 'object' && 'success' in rawData) {
690
- // Old format - validate as standardized response
691
- const validatedResponse = validateStandardizedResponse<TData>(rawData, endpoint);
692
- return this.convertToApiResult(validatedResponse);
693
- } else {
694
- // New format - raw data, wrap it in success envelope for compatibility
695
- console.log(`🔄 Converting raw response to standardized format for ${endpoint}`);
696
- const wrappedResponse: ApiSuccessResult<TData> = {
697
- success: true,
698
- data: rawData as TData,
699
- message: 'Success',
700
- operation_code: 'RAW_RESPONSE',
701
- timestamp: new Date().toISOString()
702
- };
703
- return wrappedResponse;
704
- }
705
- } catch (validationError) {
706
- // If response format is invalid, this is a CRITICAL error
707
- throw new ApiResponseFormatError(
708
- validationError instanceof Error ? validationError.message : 'Response format validation failed',
709
- endpoint,
710
- rawData
711
- );
712
- }
713
-
714
- } catch (error) {
715
- // Re-throw our custom errors as-is
716
- if (error instanceof ApiResponseFormatError ||
717
- error instanceof ApiBusinessLogicError ||
718
- error instanceof ApiValidationError ||
719
- error instanceof ApiNetworkError) {
720
- throw error;
721
- }
722
-
723
- // Wrap unknown errors as network errors
724
- console.error('❌ API request failed:', error);
725
- throw new ApiNetworkError(
726
- error instanceof Error ? error.message : 'Network error',
727
- 0,
728
- endpoint
729
- );
730
- }
731
- }
732
-
733
- /**
734
- * CONVERTS VALIDATED STANDARDIZED RESPONSE TO CLIENT RESULT
735
- * This normalizes the response for client consumption
736
- */
737
- private convertToApiResult<TData>(validatedResponse: StandardizedResponse<any>): ApiResult<TData> {
738
- if (isSuccessResponse(validatedResponse)) {
739
- return {
740
- success: true,
741
- data: validatedResponse.data as TData,
742
- message: validatedResponse.message,
743
- operation_code: validatedResponse.operation_code,
744
- timestamp: validatedResponse.timestamp
745
- } as ApiSuccessResult<TData>;
746
- }
747
-
748
- if (isPagedResponse(validatedResponse)) {
749
- return {
750
- success: true,
751
- items: validatedResponse.data as TData[],
752
- message: validatedResponse.message,
753
- operation_code: validatedResponse.operation_code,
754
- pagination: validatedResponse.pagination,
755
- timestamp: validatedResponse.timestamp
756
- } as ApiPagedResult<TData>;
757
- }
758
-
759
- if (isErrorResponse(validatedResponse)) {
760
- const reqId = (validatedResponse as any)?.request_id || (validatedResponse as any)?.requestId;
761
- if (validatedResponse.error_code === 'VALIDATION_ERROR') {
762
- // Handle validation error
763
- const valError = validatedResponse as any;
764
- return {
765
- success: false,
766
- error_code: validatedResponse.error_code,
767
- message: validatedResponse.message,
768
- operation: validatedResponse.operation,
769
- details: validatedResponse.details,
770
- validation_errors: valError.payload?.validation_errors,
771
- ...(reqId ? { request_id: reqId } : {})
772
- } as ApiErrorResult;
773
- } else {
774
- // Handle regular error
775
- return {
776
- success: false,
777
- error_code: validatedResponse.error_code,
778
- message: validatedResponse.message,
779
- operation: validatedResponse.operation,
780
- details: validatedResponse.details,
781
- ...(reqId ? { request_id: reqId } : {})
782
- } as ApiErrorResult;
783
- }
784
- }
785
-
786
- // This should never happen due to validation, but TypeScript requires it
787
- throw new ApiResponseFormatError('Unknown response type after validation', 'unknown', validatedResponse);
788
- }
789
-
790
- // ========================================================================================
791
- // HTTP METHOD WRAPPERS - PUBLIC API
792
- // ========================================================================================
793
-
794
- /**
795
- * GET REQUEST - Returns typed result with direct data access
796
- */
797
- async get<TData = unknown>(endpoint: string, sessionToken?: string): Promise<ApiResult<TData>> {
798
- return this.makeRequest<TData>(endpoint, { method: 'GET' }, sessionToken);
799
- }
800
-
801
- /**
802
- * POST REQUEST - Returns typed result with direct data access
803
- */
804
- async post<TData = unknown>(endpoint: string, data?: unknown, sessionToken?: string): Promise<ApiResult<TData>> {
805
- return this.makeRequest<TData>(endpoint, {
806
- method: 'POST',
807
- body: data ? JSON.stringify(data) : undefined,
808
- }, sessionToken);
809
- }
810
-
811
- /**
812
- * PUT REQUEST - Returns typed result with direct data access
813
- */
814
- async put<TData = unknown>(endpoint: string, data?: unknown, sessionToken?: string): Promise<ApiResult<TData>> {
815
- return this.makeRequest<TData>(endpoint, {
816
- method: 'PUT',
817
- body: data ? JSON.stringify(data) : undefined,
818
- }, sessionToken);
819
- }
820
-
821
- /**
822
- * DELETE REQUEST - Returns typed result with direct data access
823
- */
824
- async delete<TData = unknown>(endpoint: string): Promise<ApiResult<TData>> {
825
- return this.makeRequest<TData>(endpoint, { method: 'DELETE' });
826
- }
827
- }
828
-
829
- // ========================================================================================
830
- // SINGLETON INSTANCE - READY TO USE
831
- // ========================================================================================
832
-
833
- export const standardizedApi = new StandardizedClientApiService();
834
-
835
- // ========================================================================================
836
- // CONVENIENCE HELPER FUNCTIONS
837
- // ========================================================================================
838
-
839
- /**
840
- * TYPE-SAFE SUCCESS CHECK
841
- * Use this to check if API call was successful with proper type narrowing
842
- */
843
- export function isApiSuccess<TData>(result: ApiResult<TData>): result is ApiSuccessResult<TData> {
844
- return result.success === true && 'data' in result;
845
- }
846
-
847
- /**
848
- * TYPE-SAFE PAGED SUCCESS CHECK
849
- * Use this to check if API call was successful paged response with proper type narrowing
850
- */
851
- export function isApiPagedSuccess<TData>(result: ApiResult<TData>): result is ApiPagedResult<TData> {
852
- return result.success === true && 'items' in result;
853
- }
854
-
855
- /**
856
- * TYPE-SAFE ERROR CHECK
857
- * Use this to check if API call failed with proper type narrowing
858
- */
859
- export function isApiError<TData>(result: ApiResult<TData>): result is ApiErrorResult {
860
- return result.success === false;
861
- }
862
-
863
- /**
864
- * EXTRACT DATA FROM SUCCESS RESULT
865
- * Use this to get the data from a successful API call
866
- * Will throw if result is not successful
867
- */
868
- export function extractApiData<TData>(result: ApiResult<TData>): TData {
869
- if (isApiSuccess(result)) {
870
- return result.data;
871
- }
872
- if (isApiPagedSuccess(result)) {
873
- return result.items as TData;
874
- }
875
- throw new ApiBusinessLogicError(
876
- result.error_code,
877
- result.message,
878
- result.operation,
879
- result.details
880
- );
881
- }
882
-
883
- /**
884
- * EXTRACT ITEMS FROM PAGED SUCCESS RESULT
885
- * Use this to get the items array from a successful paged API call
886
- * Will throw if result is not successful paged response
887
- */
888
- export function extractApiItems<TData>(result: ApiResult<TData>): TData[] {
889
- if (isApiPagedSuccess(result)) {
890
- return result.items as TData[];
891
- }
892
- if (isApiSuccess(result)) {
893
- // If it's a regular success but expected paged, data should be array
894
- return result.data as TData[];
895
- }
896
- throw new ApiBusinessLogicError(
897
- result.error_code,
898
- result.message,
899
- result.operation,
900
- result.details
901
- );
1
+ "use client";
2
+
3
+ // ========================================================================================
4
+ // BULLETPROOF STANDARDIZED CLIENT API - ZERO TOLERANCE FOR BAD RESPONSES
5
+ // ========================================================================================
6
+ // This client API ENFORCES the standardized response format
7
+ // It will BREAK if APIs don't return the expected structure
8
+ // NO MORE GUESSING data.data.data.data - EVER AGAIN!
9
+ // ========================================================================================
10
+
11
+ import { authClient } from '../client/better-auth-client';
12
+
13
+ /** Unwrap Better Auth getSession() to a flat session-like object for backward compat */
14
+ async function getSession(): Promise<any> {
15
+ const { data } = await authClient.getSession();
16
+ return data ?? null;
17
+ }
18
+
19
+ import {
20
+ StandardizedResponse,
21
+ StandardizedApiResponse,
22
+ StandardizedPagedResponse,
23
+ StandardizedErrorResponse,
24
+ validateStandardizedResponse,
25
+ isSuccessResponse,
26
+ isPagedResponse,
27
+ isErrorResponse
28
+ } from './types/api-responses';
29
+
30
+
31
+
32
+ // ========================================================================================
33
+ // CLIENT API ERROR TYPES
34
+ // ========================================================================================
35
+
36
+ /**
37
+ * ERROR THROWN WHEN API RESPONSE FORMAT IS INVALID
38
+ * This means the API is NOT following our standardized format
39
+ */
40
+ export class ApiResponseFormatError extends Error {
41
+ constructor(message: string, public readonly endpoint: string, public readonly rawResponse: unknown) {
42
+ super(`API_FORMAT_ERROR: ${message}`);
43
+ this.name = 'ApiResponseFormatError';
44
+ }
45
+ }
46
+
47
+ /**
48
+ * ERROR THROWN WHEN API RETURNS A STANDARDIZED ERROR RESPONSE
49
+ * This is a properly formatted error from the API
50
+ */
51
+ export class ApiBusinessLogicError extends Error {
52
+ constructor(
53
+ public readonly errorCode: string,
54
+ message: string,
55
+ public readonly operation: string,
56
+ public readonly details?: unknown
57
+ ) {
58
+ super(message);
59
+ this.name = 'ApiBusinessLogicError';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * ERROR THROWN WHEN VALIDATION FAILS
65
+ * This is a properly formatted validation error from the API
66
+ */
67
+ export class ApiValidationError extends Error {
68
+ constructor(
69
+ message: string,
70
+ public readonly operation: string,
71
+ public readonly validationErrors: Record<string, string[]>,
72
+ public readonly invalidValue?: unknown,
73
+ public readonly primaryField?: string
74
+ ) {
75
+ super(message);
76
+ this.name = 'ApiValidationError';
77
+ }
78
+ }
79
+
80
+ /**
81
+ * ERROR THROWN WHEN NETWORK/HTTP ISSUES OCCUR
82
+ */
83
+ export class ApiNetworkError extends Error {
84
+ constructor(message: string, public readonly status: number, public readonly endpoint: string) {
85
+ super(`NETWORK_ERROR: ${message}`);
86
+ this.name = 'ApiNetworkError';
87
+ }
88
+ }
89
+
90
+ // ========================================================================================
91
+ // AUTHENTICATION STATE MANAGEMENT
92
+ // ========================================================================================
93
+
94
+ // Coordinate client-side refresh to avoid duplicate refresh calls racing with
95
+ // server-side middleware or other tabs. Only one refresh runs at a time.
96
+ let refreshInFlight: Promise<boolean> | null = null;
97
+
98
+ // Enhanced redirect logic with grace period and retry attempts
99
+ let authRedirectScheduled = false;
100
+ let lastAuthFailureTime = 0;
101
+ let consecutiveAuthFailures = 0;
102
+ const AUTH_FAILURE_GRACE_PERIOD = 2000; // 2 seconds grace period
103
+ const MAX_AUTH_FAILURES_BEFORE_REDIRECT = 2; // Allow 2 failures before redirect
104
+ const AUTH_FAILURE_RESET_WINDOW = 30000; // Reset failure count after 30 seconds
105
+
106
+ // Helper: detect pre-2FA session (session exists, requires 2FA and not completed)
107
+ function isPreTwoFactorSession(session: any | null | undefined): boolean {
108
+ return !!(session?.user?.requiresTwoFactor && !session?.user?.twoFactorSessionVerified);
109
+ }
110
+
111
+ // Reset auth failure state on successful requests
112
+ function resetAuthFailureState() {
113
+ if (consecutiveAuthFailures > 0) {
114
+ console.log(`✅ Resetting auth failure state (was ${consecutiveAuthFailures} failures)`);
115
+ consecutiveAuthFailures = 0;
116
+ lastAuthFailureTime = 0;
117
+ authRedirectScheduled = false;
118
+ }
119
+ }
120
+
121
+ function scheduleLoginRedirect(isImmediate = false) {
122
+ if (authRedirectScheduled) return;
123
+
124
+ const now = Date.now();
125
+
126
+ // Reset consecutive failures if enough time has passed
127
+ if (now - lastAuthFailureTime > AUTH_FAILURE_RESET_WINDOW) {
128
+ consecutiveAuthFailures = 0;
129
+ }
130
+
131
+ consecutiveAuthFailures++;
132
+ lastAuthFailureTime = now;
133
+
134
+ console.warn(`🔴 Auth failure #${consecutiveAuthFailures}, immediate: ${isImmediate}`);
135
+
136
+ // Only redirect if we've had multiple failures or if explicitly requested
137
+ if (!isImmediate && consecutiveAuthFailures < MAX_AUTH_FAILURES_BEFORE_REDIRECT) {
138
+ console.log(`⏳ Delaying redirect - only ${consecutiveAuthFailures} failures so far`);
139
+ return;
140
+ }
141
+
142
+ authRedirectScheduled = true;
143
+
144
+ const redirectFunction = () => {
145
+ try {
146
+ const returnUrl = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
147
+ console.warn(`🔄 Redirecting to login with return URL: ${returnUrl}`);
148
+ window.location.href = `/account-auth/login?returnUrl=${returnUrl}`;
149
+ } catch (error) {
150
+ console.error('❌ Error during login redirect:', error);
151
+ // Final fallback
152
+ try {
153
+ window.location.href = '/account-auth/login';
154
+ } catch {
155
+ // no-op if window is not available
156
+ }
157
+ }
158
+ };
159
+
160
+ if (isImmediate) {
161
+ // Immediate redirect for critical auth failures
162
+ redirectFunction();
163
+ } else {
164
+ // Small delay to allow any pending requests to complete
165
+ console.log(`⏳ Scheduling redirect with ${AUTH_FAILURE_GRACE_PERIOD}ms grace period`);
166
+ setTimeout(redirectFunction, AUTH_FAILURE_GRACE_PERIOD);
167
+ }
168
+ }
169
+
170
+ // ========================================================================================
171
+ // RESULT TYPES FOR CLIENT CONSUMPTION
172
+ // ========================================================================================
173
+
174
+ /**
175
+ * SUCCESSFUL API CALL RESULT
176
+ * This is what gets returned to the calling code for successful operations
177
+ */
178
+ export interface ApiSuccessResult<TData> {
179
+ /** Always true for success */
180
+ success: true;
181
+ /** The actual data - NO NESTING! Direct access! */
182
+ data: TData;
183
+ /** Human-readable success message from API */
184
+ message: string;
185
+ /** Operation code for tracking/debugging */
186
+ operation_code: string;
187
+ /** Server timestamp (if provided) */
188
+ timestamp?: string;
189
+ }
190
+
191
+ /**
192
+ * SUCCESSFUL PAGED API CALL RESULT
193
+ * This is what gets returned for successful paged operations
194
+ */
195
+ export interface ApiPagedResult<TData> {
196
+ /** Always true for success */
197
+ success: true;
198
+ /** The actual data array - NO NESTING! Direct access! */
199
+ items: TData[];
200
+ /** Human-readable success message from API */
201
+ message: string;
202
+ /** Operation code for tracking/debugging */
203
+ operation_code: string;
204
+ /** Pagination information */
205
+ pagination: {
206
+ current_page: number;
207
+ total_pages: number;
208
+ page_size: number;
209
+ total_items: number;
210
+ has_next_page: boolean;
211
+ has_previous_page: boolean;
212
+ };
213
+ /** Server timestamp (if provided) */
214
+ timestamp?: string;
215
+ }
216
+
217
+ /**
218
+ * FAILED API CALL RESULT
219
+ * This is what gets returned to the calling code for failed operations
220
+ */
221
+ export interface ApiErrorResult {
222
+ /** Always false for errors */
223
+ success: false;
224
+ /** Standardized error code */
225
+ error_code: string;
226
+ /** Human-readable error message */
227
+ message: string;
228
+ /** Operation that failed */
229
+ operation: string;
230
+ /** Additional error details (if any) */
231
+ details?: unknown;
232
+ /** Validation errors (if any) */
233
+ validation_errors?: Record<string, string[]>;
234
+ /** Server-provided request identifier for tracing (only set on real errors) */
235
+ request_id?: string;
236
+ }
237
+
238
+ /** UNION TYPE FOR ALL POSSIBLE API RESULTS */
239
+ export type ApiResult<TData> = ApiSuccessResult<TData> | ApiPagedResult<TData> | ApiErrorResult;
240
+
241
+ // ========================================================================================
242
+ // BULLETPROOF CLIENT API SERVICE
243
+ // ========================================================================================
244
+
245
+ class StandardizedClientApiService {
246
+ private baseUrl: string;
247
+
248
+ constructor() {
249
+ this.baseUrl = '';
250
+ }
251
+
252
+ /**
253
+ * MAKES HTTP REQUEST AND VALIDATES RESPONSE FORMAT
254
+ * This method ENFORCES standardized response format compliance
255
+ * Will throw ApiResponseFormatError if format is invalid
256
+ */
257
+ private async makeRequest<TData = unknown>(
258
+ endpoint: string,
259
+ options: RequestInit = {},
260
+ sessionToken?: string
261
+ ): Promise<ApiResult<TData>> {
262
+ const fullEndpoint = `${this.baseUrl}${endpoint}`;
263
+
264
+ try {
265
+ // Use provided token or get from NextAuth session
266
+ const currentSession = await getSession();
267
+ let token = sessionToken || currentSession?.accessToken;
268
+
269
+ // Preflight freshness check: if token is near expiry, coordinate refresh BEFORE making request
270
+ const pre2FA = isPreTwoFactorSession(currentSession);
271
+ const hasRefresh = !!currentSession?.refreshToken;
272
+ if (!pre2FA && hasRefresh) {
273
+ try {
274
+ let timeLeft: number | null = null;
275
+ if (currentSession?.accessTokenExpires) {
276
+ timeLeft = currentSession.accessTokenExpires - Date.now();
277
+ } else if (token) {
278
+ // Fallback decode only if session did not include expiry
279
+ const { jwtDecode } = await import('./jwt-decode-client');
280
+ const decoded: any = jwtDecode(token);
281
+ const expMs = decoded?.exp ? decoded.exp * 1000 : 0;
282
+ timeLeft = expMs - Date.now();
283
+ }
284
+ // Refresh if <= 60s remaining (or already expired)
285
+ if (timeLeft !== null && !Number.isNaN(timeLeft) && timeLeft <= 60000) {
286
+ console.log(`⏳ Access token expiring soon (${Math.floor(timeLeft/1000)}s). Coordinating refresh before request...`);
287
+ if (!refreshInFlight) {
288
+ refreshInFlight = (async () => {
289
+ const reqId = crypto.randomUUID();
290
+ const rr = await fetch('/api/auth/refresh', {
291
+ method: 'POST',
292
+ credentials: 'include',
293
+ headers: { 'X-Request-ID': reqId },
294
+ });
295
+ if (rr.ok || rr.status === 409) {
296
+ // ok or in-progress; give it a beat in case of 409
297
+ if (rr.status === 409) await new Promise(r => setTimeout(r, 1200));
298
+ return true;
299
+ }
300
+ if (rr.status === 401 || rr.status === 403) {
301
+ scheduleLoginRedirect();
302
+ throw new ApiNetworkError('Authentication failed - unable to refresh session', rr.status, endpoint);
303
+ }
304
+ const et = await rr.text();
305
+ throw new ApiNetworkError(et || 'Token refresh failed', rr.status, endpoint);
306
+ })().finally(() => { refreshInFlight = null; });
307
+ }
308
+ await refreshInFlight;
309
+ const newSessionAfter = await getSession();
310
+ token = newSessionAfter?.accessToken || token;
311
+ }
312
+ } catch (preErr) {
313
+ console.warn('Preflight token freshness check failed (continuing to attempt request):', preErr);
314
+ }
315
+ } else {
316
+ // Elegantly skip preflight refresh in pre-2FA window or when no refresh token exists
317
+ if (pre2FA) {
318
+ console.log('⏭️ Skipping preflight refresh: 2FA not complete (no refresh allowed yet)');
319
+ } else if (!hasRefresh) {
320
+ console.log('⏭️ Skipping preflight refresh: no refresh token present');
321
+ }
322
+ }
323
+
324
+ const config: RequestInit = {
325
+ ...options,
326
+ headers: {
327
+ 'Content-Type': 'application/json',
328
+ ...(token && { 'Authorization': `Bearer ${token}` }),
329
+ ...options.headers,
330
+ },
331
+ };
332
+
333
+ console.log(`🔄 API Request: ${options.method || 'GET'} ${fullEndpoint}`);
334
+
335
+ const response = await fetch(fullEndpoint, config);
336
+
337
+ if (!response.ok) {
338
+ // Handle coordination blocking (503) with auto-retry
339
+ if (response.status === 503) {
340
+ console.log('🔄 Got 503 Service Unavailable, attempting auto-retry for coordination...');
341
+
342
+ // Parse Retry-After header (in seconds)
343
+ const retryAfterHeader = response.headers.get('Retry-After');
344
+ let retryAfterSeconds = 1; // Default to 1 second
345
+
346
+ if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) {
347
+ retryAfterSeconds = parseInt(retryAfterHeader, 10);
348
+ }
349
+
350
+ const baseDelayMs = retryAfterSeconds * 1000;
351
+ const maxRetries = 3;
352
+
353
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
354
+ // Add jitter to prevent thundering herd
355
+ const jitterMs = Math.floor(Math.random() * 300) - 150; // ±150ms jitter
356
+ const exponentialBackoff = Math.pow(1.5, attempt - 1); // Mild exponential backoff
357
+ const delayMs = Math.max(100, baseDelayMs * exponentialBackoff + jitterMs);
358
+
359
+ console.log(`🔄 503 retry attempt ${attempt}/${maxRetries}, waiting ${delayMs}ms...`);
360
+ await new Promise(resolve => setTimeout(resolve, delayMs));
361
+
362
+ try {
363
+ const retryResponse = await fetch(fullEndpoint, config);
364
+
365
+ if (retryResponse.ok) {
366
+ console.log(`✅ 503 retry attempt ${attempt} succeeded`);
367
+ const rawData = await retryResponse.json();
368
+ resetAuthFailureState();
369
+
370
+ // COMPATIBILITY MODE: Handle both formats
371
+ if (rawData && typeof rawData === 'object' && 'success' in rawData) {
372
+ const validatedResponse = validateStandardizedResponse<TData>(rawData, endpoint);
373
+ return this.convertToApiResult(validatedResponse);
374
+ } else {
375
+ // New format - raw data
376
+ const wrappedResponse: ApiSuccessResult<TData> = {
377
+ success: true,
378
+ data: rawData as TData,
379
+ message: 'Success',
380
+ operation_code: 'RAW_RESPONSE',
381
+ timestamp: new Date().toISOString()
382
+ };
383
+ return wrappedResponse;
384
+ }
385
+ }
386
+
387
+ // If we get another 503, continue retrying
388
+ if (retryResponse.status === 503) {
389
+ console.log(`🔄 503 retry attempt ${attempt} got another 503, will retry...`);
390
+ continue;
391
+ }
392
+
393
+ // If we get a different error, break and handle it normally
394
+ console.log(`❌ 503 retry attempt ${attempt} got ${retryResponse.status}, stopping retries`);
395
+ // Fall through to handle the retry response error
396
+ const errorText = await retryResponse.text();
397
+ let errorData;
398
+ try {
399
+ errorData = JSON.parse(errorText);
400
+ if (errorData && typeof errorData === 'object' && 'success' in errorData) {
401
+ // Detect PayEz canonical error envelope and map accordingly
402
+ if (errorData.error && typeof errorData.error === 'object') {
403
+ const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
404
+ const reqIdBody = errorData?.request_id || errorData?.requestId;
405
+ const errorResult: ApiErrorResult = {
406
+ success: false,
407
+ error_code: errorData?.error?.code || errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
408
+ message: errorData?.error?.message || errorData?.message || `Request failed with status ${retryResponse.status}`,
409
+ operation: endpoint,
410
+ details: (errorData?.error?.details ?? errorData?.details) || undefined,
411
+ ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
412
+ };
413
+ return errorResult;
414
+ }
415
+ // Otherwise attempt to validate as our standardized error shape
416
+ const validatedError = validateStandardizedResponse(errorData, endpoint);
417
+ return this.convertToApiResult(validatedError) as ApiResult<TData>;
418
+ } else {
419
+ // New/unknown error format - best-effort mapping
420
+ const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
421
+ const reqIdBody = errorData?.request_id || errorData?.requestId;
422
+ const errorResult: ApiErrorResult = {
423
+ success: false,
424
+ error_code: errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
425
+ message: errorData?.message || (typeof errorData?.error === 'string' ? errorData.error : errorData?.error?.message) || errorText || `Request failed with status ${retryResponse.status}`,
426
+ operation: endpoint,
427
+ details: (errorData?.error?.details ?? errorData?.details) || undefined,
428
+ ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
429
+ };
430
+ return errorResult;
431
+ }
432
+ } catch {
433
+ const reqIdHeader2 = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
434
+ const errorResult: ApiErrorResult = {
435
+ success: false,
436
+ error_code: `HTTP_${retryResponse.status}`,
437
+ message: errorText || `Request failed with status ${retryResponse.status}`,
438
+ operation: endpoint,
439
+ ...(reqIdHeader2 ? { request_id: reqIdHeader2 } : {})
440
+ };
441
+ return errorResult;
442
+ }
443
+
444
+ } catch (retryError) {
445
+ console.log(`❌ 503 retry attempt ${attempt} failed with network error:`, retryError);
446
+ if (attempt === maxRetries) {
447
+ // If all retries failed with network errors, throw the original error
448
+ throw new ApiNetworkError('Service temporarily unavailable after retries', 503, endpoint);
449
+ }
450
+ continue;
451
+ }
452
+ }
453
+
454
+ // If we got here, all retries failed - fall through to normal error handling
455
+ console.log('❌ All 503 retry attempts exhausted, treating as error');
456
+ }
457
+
458
+ // Handle authentication errors with a single refresh+retry
459
+ if (response.status === 401) {
460
+ console.log('🔑 Got 401, checking if we have a session to refresh...');
461
+
462
+ // CRITICAL FIX: Check if we actually have a session before attempting refresh
463
+ const currentSession = await getSession();
464
+ if (!currentSession || !currentSession.accessToken) {
465
+ console.log('🚫 No valid session found, redirecting to login instead of refresh');
466
+ scheduleLoginRedirect(true); // Immediate redirect
467
+ throw new ApiNetworkError('Authentication required - no valid session', 401, endpoint);
468
+ }
469
+
470
+ // Elegantly gate refresh during pre-2FA or when no refresh token exists
471
+ const pre2FA_now = isPreTwoFactorSession(currentSession);
472
+ const hasRefresh_now = !!currentSession?.refreshToken;
473
+ if (pre2FA_now || !hasRefresh_now) {
474
+ console.log('⏭️ Skipping 401-driven refresh:', {
475
+ reason: pre2FA_now ? 'pre-2FA session' : 'no refresh token',
476
+ requiresTwoFactor: (currentSession?.user as any)?.requiresTwoFactor,
477
+ twoFactorVerified: (currentSession?.user as any)?.twoFactorSessionVerified,
478
+ hasRefreshToken: hasRefresh_now
479
+ });
480
+ // CRITICAL: Redirect to login immediately if refresh is impossible
481
+ console.log('🚫 Cannot refresh session, redirecting to login');
482
+ scheduleLoginRedirect(true); // Immediate redirect
483
+ throw new ApiNetworkError(
484
+ pre2FA_now
485
+ ? 'Two-factor authentication required'
486
+ : 'Session expired - refresh token unavailable',
487
+ 401,
488
+ endpoint
489
+ );
490
+ }
491
+
492
+ console.log('🔑 Valid session found, attempting token refresh...');
493
+
494
+ // Try to refresh the token, but coordinate to avoid double refresh
495
+ if (!refreshInFlight) {
496
+ refreshInFlight = (async () => {
497
+ const reqId = crypto.randomUUID();
498
+ const refreshResponse = await fetch('/api/auth/refresh', {
499
+ method: 'POST',
500
+ credentials: 'include',
501
+ headers: { 'X-Request-ID': reqId },
502
+ });
503
+
504
+ if (refreshResponse.ok) {
505
+ console.log('✅ Token refreshed successfully (client-side coordinator)');
506
+ return true;
507
+ }
508
+
509
+ // If refresh is already in progress server-side, wait briefly and allow retry
510
+ if (refreshResponse.status === 409) {
511
+ console.log('↪️ Refresh in progress server-side (409). Waiting for completion...');
512
+ await new Promise(r => setTimeout(r, 1500));
513
+ return true;
514
+ }
515
+
516
+ // For auth failures, schedule redirect; for others, throw
517
+ if (refreshResponse.status === 401 || refreshResponse.status === 403) {
518
+ scheduleLoginRedirect();
519
+ throw new ApiNetworkError('Authentication failed - unable to refresh session', refreshResponse.status, endpoint);
520
+ }
521
+
522
+ const errorText = await refreshResponse.text();
523
+ throw new ApiNetworkError(errorText || 'Token refresh failed', refreshResponse.status, endpoint);
524
+ })().finally(() => { refreshInFlight = null; });
525
+ }
526
+
527
+ try {
528
+ await refreshInFlight;
529
+ } catch (e) {
530
+ throw e; // bubble up to caller handling
531
+ }
532
+
533
+ console.log('🔁 Retrying original request after coordinated refresh...');
534
+ // Get the new session and retry the original request
535
+ const newSession = await getSession();
536
+ const newToken = newSession?.accessToken;
537
+ const retryConfig: RequestInit = {
538
+ ...options,
539
+ headers: {
540
+ 'Content-Type': 'application/json',
541
+ ...(newToken && { 'Authorization': `Bearer ${newToken}` }),
542
+ ...options.headers,
543
+ },
544
+ };
545
+
546
+ const retryResponse = await fetch(fullEndpoint, retryConfig);
547
+
548
+ if (retryResponse.ok) {
549
+ const rawData = await retryResponse.json();
550
+ // Reset auth failure state on successful retry
551
+ resetAuthFailureState();
552
+
553
+ // COMPATIBILITY MODE: Handle both formats in retry as well
554
+ if (rawData && typeof rawData === 'object' && 'success' in rawData) {
555
+ const validatedResponse = validateStandardizedResponse<TData>(rawData, endpoint);
556
+ return this.convertToApiResult(validatedResponse);
557
+ } else {
558
+ // New format - raw data
559
+ console.log(`🔄 Converting raw retry response to standardized format for ${endpoint}`);
560
+ const wrappedResponse: ApiSuccessResult<TData> = {
561
+ success: true,
562
+ data: rawData as TData,
563
+ message: 'Success',
564
+ operation_code: 'RAW_RESPONSE',
565
+ timestamp: new Date().toISOString()
566
+ };
567
+ return wrappedResponse;
568
+ }
569
+ } else {
570
+ // If retry still 401, schedule redirect
571
+ if (retryResponse.status === 401) {
572
+ scheduleLoginRedirect();
573
+ }
574
+
575
+ const errorText = await retryResponse.text();
576
+ let errorData;
577
+ try {
578
+ errorData = JSON.parse(errorText);
579
+ // Check if it has the success field (old format)
580
+ if (errorData && typeof errorData === 'object' && 'success' in errorData) {
581
+ // Detect PayEz canonical error envelope and map accordingly
582
+ if (errorData.error && typeof errorData.error === 'object') {
583
+ const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
584
+ const reqIdBody = errorData?.request_id || errorData?.requestId;
585
+ const errorResult: ApiErrorResult = {
586
+ success: false,
587
+ error_code: errorData?.error?.code || errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
588
+ message: errorData?.error?.message || errorData?.message || `Request failed with status ${retryResponse.status}`,
589
+ operation: endpoint,
590
+ details: (errorData?.error?.details ?? errorData?.details) || undefined,
591
+ ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
592
+ };
593
+ return errorResult;
594
+ }
595
+ const validatedError = validateStandardizedResponse(errorData, endpoint);
596
+ return this.convertToApiResult(validatedError) as ApiResult<TData>;
597
+ } else {
598
+ // New format - convert raw error to standardized format
599
+ const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
600
+ const reqIdBody = errorData?.request_id || errorData?.requestId;
601
+ const errorResult: ApiErrorResult = {
602
+ success: false,
603
+ error_code: errorData?.error_code || errorData?.code || `HTTP_${retryResponse.status}`,
604
+ message: errorData?.message || (typeof errorData?.error === 'string' ? errorData.error : errorData?.error?.message) || errorText || `Request failed with status ${retryResponse.status}`,
605
+ operation: endpoint,
606
+ details: (errorData?.error?.details ?? errorData?.details) || undefined,
607
+ ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
608
+ };
609
+ return errorResult;
610
+ }
611
+ } catch {
612
+ // If we can't parse the error, create a generic error response
613
+ const reqIdHeader = retryResponse.headers.get('X-Request-ID') || retryResponse.headers.get('X-Correlation-ID');
614
+ const errorResult: ApiErrorResult = {
615
+ success: false,
616
+ error_code: `HTTP_${retryResponse.status}`,
617
+ message: errorText || `Request failed with status ${retryResponse.status}`,
618
+ operation: endpoint,
619
+ ...(reqIdHeader ? { request_id: reqIdHeader } : {})
620
+ };
621
+ return errorResult;
622
+ }
623
+ }
624
+ }
625
+
626
+ // Non-401 error: try to parse as standardized error response
627
+ const errorText = await response.text();
628
+ let errorData;
629
+ try {
630
+ errorData = JSON.parse(errorText);
631
+ // Check if it has the success field (old format)
632
+ if (errorData && typeof errorData === 'object' && 'success' in errorData) {
633
+ // Detect PayEz canonical error envelope and map accordingly
634
+ if (errorData.error && typeof errorData.error === 'object') {
635
+ const reqIdHeader = response.headers.get('X-Request-ID') || response.headers.get('X-Correlation-ID');
636
+ const reqIdBody = errorData?.request_id || errorData?.requestId;
637
+ const errorResult: ApiErrorResult = {
638
+ success: false,
639
+ error_code: errorData?.error?.code || errorData?.error_code || errorData?.code || `HTTP_${response.status}`,
640
+ message: errorData?.error?.message || errorData?.message || `Request failed with status ${response.status}`,
641
+ operation: endpoint,
642
+ details: (errorData?.error?.details ?? errorData?.details) || undefined,
643
+ validation_errors: errorData?.validation_errors || undefined,
644
+ ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
645
+ };
646
+ return errorResult;
647
+ }
648
+ const validatedError = validateStandardizedResponse(errorData, endpoint);
649
+ return this.convertToApiResult(validatedError) as ApiResult<TData>;
650
+ } else {
651
+ // New format - convert raw error to standardized format
652
+ const reqIdHeader = response.headers.get('X-Request-ID') || response.headers.get('X-Correlation-ID');
653
+ const reqIdBody = errorData?.request_id || errorData?.requestId;
654
+ const errorResult: ApiErrorResult = {
655
+ success: false,
656
+ error_code: errorData?.error_code || errorData?.code || `HTTP_${response.status}`,
657
+ message: errorData?.message || (typeof errorData?.error === 'string' ? errorData.error : errorData?.error?.message) || errorText || `Request failed with status ${response.status}`,
658
+ operation: endpoint,
659
+ details: (errorData?.error?.details ?? errorData?.details) || undefined,
660
+ validation_errors: errorData?.validation_errors || undefined,
661
+ ...(reqIdBody || reqIdHeader ? { request_id: (reqIdBody || reqIdHeader) as string } : {})
662
+ };
663
+ return errorResult;
664
+ }
665
+ } catch (parseError) {
666
+ // If we can't parse the error, create a generic error response
667
+ const reqIdHeader = response.headers.get('X-Request-ID') || response.headers.get('X-Correlation-ID');
668
+ const errorResult: ApiErrorResult = {
669
+ success: false,
670
+ error_code: `HTTP_${response.status}`,
671
+ message: errorText || `Request failed with status ${response.status}`,
672
+ operation: endpoint,
673
+ details: undefined,
674
+ ...(reqIdHeader ? { request_id: reqIdHeader } : {})
675
+ };
676
+ return errorResult;
677
+ }
678
+ }
679
+
680
+ // SUCCESS PATH: Parse and validate response
681
+ const rawData = await response.json();
682
+
683
+ // Reset auth failure state on successful request
684
+ resetAuthFailureState();
685
+
686
+ // COMPATIBILITY MODE: Handle both old envelope format and new raw format
687
+ try {
688
+ // First check if it's the old standardized format with success field
689
+ if (rawData && typeof rawData === 'object' && 'success' in rawData) {
690
+ // Old format - validate as standardized response
691
+ const validatedResponse = validateStandardizedResponse<TData>(rawData, endpoint);
692
+ return this.convertToApiResult(validatedResponse);
693
+ } else {
694
+ // New format - raw data, wrap it in success envelope for compatibility
695
+ console.log(`🔄 Converting raw response to standardized format for ${endpoint}`);
696
+ const wrappedResponse: ApiSuccessResult<TData> = {
697
+ success: true,
698
+ data: rawData as TData,
699
+ message: 'Success',
700
+ operation_code: 'RAW_RESPONSE',
701
+ timestamp: new Date().toISOString()
702
+ };
703
+ return wrappedResponse;
704
+ }
705
+ } catch (validationError) {
706
+ // If response format is invalid, this is a CRITICAL error
707
+ throw new ApiResponseFormatError(
708
+ validationError instanceof Error ? validationError.message : 'Response format validation failed',
709
+ endpoint,
710
+ rawData
711
+ );
712
+ }
713
+
714
+ } catch (error) {
715
+ // Re-throw our custom errors as-is
716
+ if (error instanceof ApiResponseFormatError ||
717
+ error instanceof ApiBusinessLogicError ||
718
+ error instanceof ApiValidationError ||
719
+ error instanceof ApiNetworkError) {
720
+ throw error;
721
+ }
722
+
723
+ // Wrap unknown errors as network errors
724
+ console.error('❌ API request failed:', error);
725
+ throw new ApiNetworkError(
726
+ error instanceof Error ? error.message : 'Network error',
727
+ 0,
728
+ endpoint
729
+ );
730
+ }
731
+ }
732
+
733
+ /**
734
+ * CONVERTS VALIDATED STANDARDIZED RESPONSE TO CLIENT RESULT
735
+ * This normalizes the response for client consumption
736
+ */
737
+ private convertToApiResult<TData>(validatedResponse: StandardizedResponse<any>): ApiResult<TData> {
738
+ if (isSuccessResponse(validatedResponse)) {
739
+ return {
740
+ success: true,
741
+ data: validatedResponse.data as TData,
742
+ message: validatedResponse.message,
743
+ operation_code: validatedResponse.operation_code,
744
+ timestamp: validatedResponse.timestamp
745
+ } as ApiSuccessResult<TData>;
746
+ }
747
+
748
+ if (isPagedResponse(validatedResponse)) {
749
+ return {
750
+ success: true,
751
+ items: validatedResponse.data as TData[],
752
+ message: validatedResponse.message,
753
+ operation_code: validatedResponse.operation_code,
754
+ pagination: validatedResponse.pagination,
755
+ timestamp: validatedResponse.timestamp
756
+ } as ApiPagedResult<TData>;
757
+ }
758
+
759
+ if (isErrorResponse(validatedResponse)) {
760
+ const reqId = (validatedResponse as any)?.request_id || (validatedResponse as any)?.requestId;
761
+ if (validatedResponse.error_code === 'VALIDATION_ERROR') {
762
+ // Handle validation error
763
+ const valError = validatedResponse as any;
764
+ return {
765
+ success: false,
766
+ error_code: validatedResponse.error_code,
767
+ message: validatedResponse.message,
768
+ operation: validatedResponse.operation,
769
+ details: validatedResponse.details,
770
+ validation_errors: valError.payload?.validation_errors,
771
+ ...(reqId ? { request_id: reqId } : {})
772
+ } as ApiErrorResult;
773
+ } else {
774
+ // Handle regular error
775
+ return {
776
+ success: false,
777
+ error_code: validatedResponse.error_code,
778
+ message: validatedResponse.message,
779
+ operation: validatedResponse.operation,
780
+ details: validatedResponse.details,
781
+ ...(reqId ? { request_id: reqId } : {})
782
+ } as ApiErrorResult;
783
+ }
784
+ }
785
+
786
+ // This should never happen due to validation, but TypeScript requires it
787
+ throw new ApiResponseFormatError('Unknown response type after validation', 'unknown', validatedResponse);
788
+ }
789
+
790
+ // ========================================================================================
791
+ // HTTP METHOD WRAPPERS - PUBLIC API
792
+ // ========================================================================================
793
+
794
+ /**
795
+ * GET REQUEST - Returns typed result with direct data access
796
+ */
797
+ async get<TData = unknown>(endpoint: string, sessionToken?: string): Promise<ApiResult<TData>> {
798
+ return this.makeRequest<TData>(endpoint, { method: 'GET' }, sessionToken);
799
+ }
800
+
801
+ /**
802
+ * POST REQUEST - Returns typed result with direct data access
803
+ */
804
+ async post<TData = unknown>(endpoint: string, data?: unknown, sessionToken?: string): Promise<ApiResult<TData>> {
805
+ return this.makeRequest<TData>(endpoint, {
806
+ method: 'POST',
807
+ body: data ? JSON.stringify(data) : undefined,
808
+ }, sessionToken);
809
+ }
810
+
811
+ /**
812
+ * PUT REQUEST - Returns typed result with direct data access
813
+ */
814
+ async put<TData = unknown>(endpoint: string, data?: unknown, sessionToken?: string): Promise<ApiResult<TData>> {
815
+ return this.makeRequest<TData>(endpoint, {
816
+ method: 'PUT',
817
+ body: data ? JSON.stringify(data) : undefined,
818
+ }, sessionToken);
819
+ }
820
+
821
+ /**
822
+ * DELETE REQUEST - Returns typed result with direct data access
823
+ */
824
+ async delete<TData = unknown>(endpoint: string): Promise<ApiResult<TData>> {
825
+ return this.makeRequest<TData>(endpoint, { method: 'DELETE' });
826
+ }
827
+ }
828
+
829
+ // ========================================================================================
830
+ // SINGLETON INSTANCE - READY TO USE
831
+ // ========================================================================================
832
+
833
+ export const standardizedApi = new StandardizedClientApiService();
834
+
835
+ // ========================================================================================
836
+ // CONVENIENCE HELPER FUNCTIONS
837
+ // ========================================================================================
838
+
839
+ /**
840
+ * TYPE-SAFE SUCCESS CHECK
841
+ * Use this to check if API call was successful with proper type narrowing
842
+ */
843
+ export function isApiSuccess<TData>(result: ApiResult<TData>): result is ApiSuccessResult<TData> {
844
+ return result.success === true && 'data' in result;
845
+ }
846
+
847
+ /**
848
+ * TYPE-SAFE PAGED SUCCESS CHECK
849
+ * Use this to check if API call was successful paged response with proper type narrowing
850
+ */
851
+ export function isApiPagedSuccess<TData>(result: ApiResult<TData>): result is ApiPagedResult<TData> {
852
+ return result.success === true && 'items' in result;
853
+ }
854
+
855
+ /**
856
+ * TYPE-SAFE ERROR CHECK
857
+ * Use this to check if API call failed with proper type narrowing
858
+ */
859
+ export function isApiError<TData>(result: ApiResult<TData>): result is ApiErrorResult {
860
+ return result.success === false;
861
+ }
862
+
863
+ /**
864
+ * EXTRACT DATA FROM SUCCESS RESULT
865
+ * Use this to get the data from a successful API call
866
+ * Will throw if result is not successful
867
+ */
868
+ export function extractApiData<TData>(result: ApiResult<TData>): TData {
869
+ if (isApiSuccess(result)) {
870
+ return result.data;
871
+ }
872
+ if (isApiPagedSuccess(result)) {
873
+ return result.items as TData;
874
+ }
875
+ throw new ApiBusinessLogicError(
876
+ result.error_code,
877
+ result.message,
878
+ result.operation,
879
+ result.details
880
+ );
881
+ }
882
+
883
+ /**
884
+ * EXTRACT ITEMS FROM PAGED SUCCESS RESULT
885
+ * Use this to get the items array from a successful paged API call
886
+ * Will throw if result is not successful paged response
887
+ */
888
+ export function extractApiItems<TData>(result: ApiResult<TData>): TData[] {
889
+ if (isApiPagedSuccess(result)) {
890
+ return result.items as TData[];
891
+ }
892
+ if (isApiSuccess(result)) {
893
+ // If it's a regular success but expected paged, data should be array
894
+ return result.data as TData[];
895
+ }
896
+ throw new ApiBusinessLogicError(
897
+ result.error_code,
898
+ result.message,
899
+ result.operation,
900
+ result.details
901
+ );
902
902
  }