@payez/next-mvp 4.8.1 → 4.8.2

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 (302) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/components/account/MobileNavDrawer.js +18 -18
  4. package/dist/components/account/UserAvatarMenu.js +1 -1
  5. package/dist/components/admin/AlertSettingsTab.js +40 -40
  6. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  7. package/dist/lib/session-store.js +21 -21
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/pages/login/page.js +28 -28
  10. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  11. package/dist/vibe/sessions.js +28 -3
  12. package/package.json +2 -2
  13. package/scripts/check-publish-eol.mjs +33 -15
  14. package/src/api/auth-handler.ts +550 -550
  15. package/src/api/index.ts +17 -17
  16. package/src/api-handlers/account/change-password.ts +170 -170
  17. package/src/api-handlers/account/masked-info.ts +46 -46
  18. package/src/api-handlers/account/profile.ts +99 -99
  19. package/src/api-handlers/account/recovery/forward.ts +117 -117
  20. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  21. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  22. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  23. package/src/api-handlers/account/reset-password.ts +87 -87
  24. package/src/api-handlers/account/send-code.ts +70 -70
  25. package/src/api-handlers/account/update-phone.ts +84 -84
  26. package/src/api-handlers/account/validate-password.ts +139 -139
  27. package/src/api-handlers/account/verify-email.ts +134 -134
  28. package/src/api-handlers/account/verify-sms.ts +134 -134
  29. package/src/api-handlers/admin/analytics.ts +452 -452
  30. package/src/api-handlers/admin/audit.ts +232 -232
  31. package/src/api-handlers/admin/index.ts +63 -63
  32. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  33. package/src/api-handlers/admin/sessions.ts +327 -327
  34. package/src/api-handlers/admin/site-logs.ts +388 -388
  35. package/src/api-handlers/admin/stats.ts +260 -260
  36. package/src/api-handlers/admin/users.ts +251 -251
  37. package/src/api-handlers/admin/vibe-data.ts +333 -333
  38. package/src/api-handlers/anon/preferences.ts +123 -123
  39. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  40. package/src/api-handlers/auth/credential-login.ts +948 -948
  41. package/src/api-handlers/auth/jwks.ts +31 -31
  42. package/src/api-handlers/auth/login.ts +252 -252
  43. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  44. package/src/api-handlers/auth/refresh.ts +829 -829
  45. package/src/api-handlers/auth/signout.ts +201 -201
  46. package/src/api-handlers/auth/status.ts +23 -23
  47. package/src/api-handlers/auth/update-session.ts +144 -144
  48. package/src/api-handlers/auth/validate.ts +45 -45
  49. package/src/api-handlers/auth/verify-code.ts +116 -116
  50. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  51. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  52. package/src/api-handlers/mfa/methods.ts +47 -47
  53. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  54. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  55. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  56. package/src/api-handlers/oauth/resume.ts +182 -182
  57. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  58. package/src/api-handlers/onboarding/set-password.ts +21 -21
  59. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  60. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  61. package/src/api-handlers/passkeys/list.ts +32 -32
  62. package/src/api-handlers/passkeys/manage.ts +55 -55
  63. package/src/api-handlers/passkeys/register-options.ts +43 -43
  64. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  65. package/src/api-handlers/session/refresh-viability.ts +56 -56
  66. package/src/api-handlers/session/viability.ts +238 -238
  67. package/src/api-handlers/test/force-expire.ts +167 -167
  68. package/src/auth/auth-decision.ts +292 -292
  69. package/src/auth/better-auth.ts +1688 -1688
  70. package/src/auth/idp-oauth.ts +310 -310
  71. package/src/auth/password-reset-bridge.ts +98 -98
  72. package/src/auth/revocation-fields.ts +16 -16
  73. package/src/auth/route-config.ts +236 -236
  74. package/src/auth/safe-callback.ts +199 -199
  75. package/src/auth/session-cookie-clear.ts +30 -30
  76. package/src/auth/session-reset-link.ts +159 -159
  77. package/src/auth/session-response-sanitizer.ts +151 -151
  78. package/src/auth/types/auth-types.ts +555 -555
  79. package/src/auth/types/index.ts +7 -7
  80. package/src/auth/unauthenticated-routes.ts +2 -2
  81. package/src/auth/utils/idp-client.ts +364 -364
  82. package/src/auth/utils/index.ts +6 -6
  83. package/src/auth/utils/token-utils.ts +243 -243
  84. package/src/client/AuthContext.tsx +192 -192
  85. package/src/client/better-auth-client.ts +70 -70
  86. package/src/client/fetch-with-auth.ts +47 -47
  87. package/src/client/fetchWithSession.ts +21 -21
  88. package/src/client/index.ts +13 -13
  89. package/src/client/useAnonSession.ts +131 -131
  90. package/src/components/SessionSync.tsx +138 -138
  91. package/src/components/SignalRHealthCheck.tsx +131 -131
  92. package/src/components/account/MobileNavDrawer.tsx +305 -305
  93. package/src/components/account/UserAvatarMenu.tsx +277 -277
  94. package/src/components/account/index.ts +13 -13
  95. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  96. package/src/components/admin/AnalyticsTab.tsx +703 -703
  97. package/src/components/admin/DataBrowserTab.tsx +508 -508
  98. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  99. package/src/components/admin/SessionsTab.tsx +421 -421
  100. package/src/components/admin/StatsTab.tsx +379 -379
  101. package/src/components/admin/VibeAdminContext.tsx +87 -87
  102. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  103. package/src/components/admin/index.ts +59 -59
  104. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  105. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  106. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  107. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  108. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  109. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  110. package/src/components/recovery/CompleteStep.tsx +36 -36
  111. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  112. package/src/components/recovery/RequestedStep.tsx +30 -30
  113. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  114. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  115. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  116. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  117. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  118. package/src/components/ui/BetaBadge.tsx +58 -58
  119. package/src/components/ui/Footer.tsx +93 -93
  120. package/src/config/env.ts +55 -55
  121. package/src/config/logger.ts +62 -62
  122. package/src/config/logging-config.ts +82 -82
  123. package/src/config/unauthenticated-routes.ts +19 -19
  124. package/src/config/vibe-log-transport.ts +264 -264
  125. package/src/edge/internal-api-url.ts +65 -65
  126. package/src/edge/middleware.ts +42 -42
  127. package/src/hooks/useAuth.ts +117 -117
  128. package/src/hooks/useAuthSettings.ts +109 -109
  129. package/src/hooks/useAvailableProviders.ts +143 -143
  130. package/src/hooks/usePasswordValidation.ts +127 -127
  131. package/src/hooks/useProfile.ts +82 -82
  132. package/src/hooks/usePublicAuthSettings.ts +171 -171
  133. package/src/hooks/useSessionExpiration.ts +101 -101
  134. package/src/hooks/useViabilitySession.ts +337 -337
  135. package/src/index.ts +64 -64
  136. package/src/lib/anon-session.ts +213 -213
  137. package/src/lib/api-handler.ts +643 -643
  138. package/src/lib/app-slug.ts +228 -228
  139. package/src/lib/authorize-resume.ts +172 -172
  140. package/src/lib/bearer-fetch.ts +31 -31
  141. package/src/lib/client-key.ts +88 -88
  142. package/src/lib/code-step-copy.ts +83 -83
  143. package/src/lib/credential-sign-in-client.ts +101 -101
  144. package/src/lib/demo-mode.ts +18 -18
  145. package/src/lib/device-id.ts +67 -67
  146. package/src/lib/ensure-fresh-access-token.ts +509 -509
  147. package/src/lib/geolocation.ts +265 -265
  148. package/src/lib/idp-client-config.ts +1005 -1005
  149. package/src/lib/idp-fetch.ts +169 -169
  150. package/src/lib/idp-url-guard.ts +37 -37
  151. package/src/lib/internal-api.ts +171 -171
  152. package/src/lib/jwt-decode-client.ts +45 -45
  153. package/src/lib/jwt-decode.ts +82 -82
  154. package/src/lib/lock-window.ts +49 -49
  155. package/src/lib/login-error-message.ts +41 -41
  156. package/src/lib/never-in-response.ts +64 -64
  157. package/src/lib/nextauth-secret.ts +168 -168
  158. package/src/lib/onboarding-proxy.ts +57 -57
  159. package/src/lib/password-rules.ts +50 -50
  160. package/src/lib/rate-limit-service.ts +9 -9
  161. package/src/lib/redis.ts +105 -105
  162. package/src/lib/refresh-token-validator.ts +64 -64
  163. package/src/lib/roles.ts +177 -177
  164. package/src/lib/safe-error-response.ts +149 -149
  165. package/src/lib/secret-validation.ts +8 -8
  166. package/src/lib/session-cookie-shape.ts +185 -185
  167. package/src/lib/session-store.ts +1092 -1092
  168. package/src/lib/session.ts +34 -34
  169. package/src/lib/single-flight.ts +64 -64
  170. package/src/lib/site-logger.ts +245 -245
  171. package/src/lib/standardized-client-api.ts +901 -901
  172. package/src/lib/startup-init.ts +276 -276
  173. package/src/lib/test-aware-get-token.ts +41 -41
  174. package/src/lib/token-expiry.ts +40 -40
  175. package/src/lib/token-lifecycle.ts +546 -546
  176. package/src/lib/token-log-id.ts +69 -69
  177. package/src/lib/twofa-rejection.ts +91 -91
  178. package/src/lib/types/api-responses.ts +336 -336
  179. package/src/lib/user-agent-parser.ts +252 -252
  180. package/src/logging/api/admin-analytics.ts +51 -51
  181. package/src/logging/api/audit-log.ts +53 -53
  182. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  183. package/src/logging/components/AuditLogViewer.tsx +125 -125
  184. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  185. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  186. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  187. package/src/logging/hooks/useAuditLog.ts +24 -24
  188. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  189. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  190. package/src/logging/index.ts +18 -18
  191. package/src/logging/types/analytics.ts +81 -81
  192. package/src/logging/types/audit.ts +31 -31
  193. package/src/logging/types/index.ts +3 -3
  194. package/src/middleware/create-middleware.ts +1141 -1141
  195. package/src/middleware/rbac-check.ts +279 -279
  196. package/src/middleware/twofa-presets.ts +224 -224
  197. package/src/models/DecodedAccessToken.ts +16 -16
  198. package/src/models/SessionModel.ts +331 -331
  199. package/src/pages/admin-login/page.tsx +111 -111
  200. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  201. package/src/pages/admin-page-permissions/index.ts +7 -7
  202. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  203. package/src/pages/admin-roles/index.ts +9 -9
  204. package/src/pages/admin-roles/modals.tsx +469 -469
  205. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  206. package/src/pages/client-admin/index.ts +33 -33
  207. package/src/pages/coming-soon/page.tsx +83 -83
  208. package/src/pages/login/page.tsx +338 -338
  209. package/src/pages/onboarding/index.ts +11 -11
  210. package/src/pages/onboarding/page.tsx +212 -212
  211. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  212. package/src/pages/profile/index.ts +9 -9
  213. package/src/pages/profile/page.tsx +166 -166
  214. package/src/pages/recovery/page.tsx +267 -267
  215. package/src/pages/roles/MyRolesPage.tsx +211 -211
  216. package/src/pages/roles/components.tsx +294 -294
  217. package/src/pages/roles/index.ts +17 -17
  218. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  219. package/src/pages/security/index.ts +12 -12
  220. package/src/pages/security/page.tsx +507 -507
  221. package/src/pages/service-unavailable/page.tsx +63 -63
  222. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  223. package/src/pages/settings/index.ts +9 -9
  224. package/src/pages/settings/page.tsx +47 -47
  225. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  226. package/src/pages/showcase/index.ts +13 -13
  227. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  228. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  229. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  230. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  231. package/src/pages/test-env/guarded.tsx +81 -81
  232. package/src/pages/test-env/index.ts +77 -77
  233. package/src/pages/test-env/redact-credentials.ts +96 -96
  234. package/src/pages/verify-code/page.tsx +746 -746
  235. package/src/routes/account/index.ts +31 -31
  236. package/src/routes/account/masked-info.ts +36 -36
  237. package/src/routes/account/send-code.ts +39 -39
  238. package/src/routes/account/update-phone.ts +13 -13
  239. package/src/routes/account/verify-email.ts +40 -40
  240. package/src/routes/account/verify-sms.ts +40 -40
  241. package/src/routes/auth/credential-login-email-code.ts +6 -6
  242. package/src/routes/auth/credential-login.ts +6 -6
  243. package/src/routes/auth/index.ts +22 -22
  244. package/src/routes/auth/logout.ts +111 -111
  245. package/src/routes/auth/nextauth.ts +45 -45
  246. package/src/routes/auth/passkey-signin.ts +13 -13
  247. package/src/routes/auth/refresh.ts +77 -77
  248. package/src/routes/auth/session-reset.ts +37 -37
  249. package/src/routes/auth/session.ts +261 -261
  250. package/src/routes/auth/settings.ts +181 -181
  251. package/src/routes/auth/viability.ts +519 -519
  252. package/src/routes/index.ts +17 -17
  253. package/src/routes/mfa/backup-code-verify.ts +1 -1
  254. package/src/routes/mfa/backup-codes.ts +10 -10
  255. package/src/routes/mfa/methods.ts +22 -22
  256. package/src/routes/mfa/totp-confirm.ts +10 -10
  257. package/src/routes/mfa/totp-enroll.ts +10 -10
  258. package/src/routes/mfa/totp-verify.ts +14 -14
  259. package/src/routes/onboarding/index.ts +15 -15
  260. package/src/routes/onboarding/send-email-code.ts +2 -2
  261. package/src/routes/onboarding/set-password.ts +2 -2
  262. package/src/routes/onboarding/verify-code.ts +2 -2
  263. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  264. package/src/routes/passkeys/list.ts +10 -10
  265. package/src/routes/passkeys/manage.ts +23 -23
  266. package/src/routes/passkeys/register-options.ts +10 -10
  267. package/src/routes/passkeys/register-verify.ts +10 -10
  268. package/src/routes/session/index.ts +7 -7
  269. package/src/routes/session/refresh-viability.ts +17 -17
  270. package/src/server/auth-guard.ts +203 -203
  271. package/src/server/auth.ts +486 -486
  272. package/src/server/decode-session.ts +309 -309
  273. package/src/server/slim-middleware.ts +117 -117
  274. package/src/server/verified-session-token.ts +103 -103
  275. package/src/server/with-auth.ts +93 -93
  276. package/src/services/signalrActivityService.ts +258 -258
  277. package/src/stores/authStore.ts +1900 -1900
  278. package/src/templates/instrumentation.ts +60 -60
  279. package/src/theme/ThemeProvider.tsx +39 -39
  280. package/src/theme/default.ts +33 -33
  281. package/src/theme/index.ts +31 -31
  282. package/src/theme/types.ts +69 -69
  283. package/src/theme/useTheme.ts +57 -57
  284. package/src/theme/utils.ts +40 -40
  285. package/src/types/api.ts +12 -12
  286. package/src/types/auth.d.ts +23 -23
  287. package/src/types/auth.ts +48 -48
  288. package/src/types/logging.ts +11 -11
  289. package/src/types/recovery.ts +54 -54
  290. package/src/utils/api.ts +401 -401
  291. package/src/utils/circuitBreaker.ts +40 -40
  292. package/src/utils/error-message.ts +108 -108
  293. package/src/utils/layout/reservedSpace.ts +124 -124
  294. package/src/utils/logout.ts +30 -30
  295. package/src/vibe/client.ts +877 -877
  296. package/src/vibe/enterprise-auth.ts +208 -208
  297. package/src/vibe/errors.ts +209 -209
  298. package/src/vibe/generic.ts +339 -339
  299. package/src/vibe/hooks/index.ts +367 -367
  300. package/src/vibe/index.ts +133 -133
  301. package/src/vibe/sessions.ts +925 -899
  302. 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
  }