@payez/next-mvp 4.1.9 → 4.1.10

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 (161) hide show
  1. package/README.md +792 -792
  2. package/dist/api-handlers/auth/refresh.js +38 -15
  3. package/dist/auth/better-auth.js +7 -18
  4. package/dist/components/account/MobileNavDrawer.js +18 -18
  5. package/dist/components/account/UserAvatarMenu.js +1 -1
  6. package/dist/components/admin/AlertSettingsTab.js +40 -40
  7. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  8. package/dist/lib/ensure-fresh-access-token.js +30 -7
  9. package/dist/lib/idp-fetch.js +25 -9
  10. package/dist/lib/session-store.d.ts +1 -0
  11. package/dist/lib/session-store.js +12 -8
  12. package/dist/pages/login/page.js +28 -28
  13. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  14. package/package.json +893 -893
  15. package/scripts/dev-broker.ps1 +35 -35
  16. package/scripts/dev-local.ps1 +45 -45
  17. package/src/api/auth-handler.ts +548 -548
  18. package/src/api-handlers/account/change-password.ts +142 -142
  19. package/src/api-handlers/account/update-phone.ts +79 -79
  20. package/src/api-handlers/account/verify-email.ts +125 -125
  21. package/src/api-handlers/account/verify-sms.ts +125 -125
  22. package/src/api-handlers/admin/analytics.ts +443 -443
  23. package/src/api-handlers/admin/audit.ts +223 -223
  24. package/src/api-handlers/admin/index.ts +63 -63
  25. package/src/api-handlers/admin/redis-sessions.ts +251 -251
  26. package/src/api-handlers/admin/sessions.ts +318 -318
  27. package/src/api-handlers/admin/site-logs.ts +365 -365
  28. package/src/api-handlers/admin/users.ts +242 -242
  29. package/src/api-handlers/admin/vibe-data.ts +324 -324
  30. package/src/api-handlers/auth/refresh.ts +705 -682
  31. package/src/api-handlers/auth/signout.ts +199 -199
  32. package/src/api-handlers/auth/status.ts +20 -20
  33. package/src/api-handlers/auth/update-session.ts +110 -110
  34. package/src/api-handlers/auth/verify-code.ts +112 -112
  35. package/src/api-handlers/session/refresh-viability.ts +36 -36
  36. package/src/api-handlers/test/force-expire.ts +60 -60
  37. package/src/auth/auth-decision.ts +229 -229
  38. package/src/auth/better-auth.ts +7 -18
  39. package/src/auth/route-config.ts +219 -219
  40. package/src/auth/types/auth-types.ts +555 -555
  41. package/src/auth/types/index.ts +7 -7
  42. package/src/auth/utils/idp-client.ts +445 -445
  43. package/src/auth/utils/index.ts +6 -6
  44. package/src/auth/utils/token-utils.ts +243 -243
  45. package/src/client/AuthContext.tsx +143 -143
  46. package/src/client/fetch-with-auth.ts +47 -47
  47. package/src/components/SessionSync.tsx +138 -138
  48. package/src/components/SignalRHealthCheck.tsx +131 -131
  49. package/src/components/account/MobileNavDrawer.tsx +305 -305
  50. package/src/components/account/UserAvatarMenu.tsx +266 -266
  51. package/src/components/account/index.ts +13 -13
  52. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  53. package/src/components/admin/AnalyticsTab.tsx +703 -703
  54. package/src/components/admin/DataBrowserTab.tsx +505 -505
  55. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  56. package/src/components/admin/SessionsTab.tsx +414 -414
  57. package/src/components/admin/StatsTab.tsx +379 -379
  58. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  59. package/src/components/admin/index.ts +59 -59
  60. package/src/components/auth/FederatedAuthSection.tsx +95 -95
  61. package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
  62. package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
  63. package/src/components/auth/TraditionalAuthSection.tsx +99 -99
  64. package/src/components/recovery/CompleteStep.tsx +36 -36
  65. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  66. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  67. package/src/components/recovery/SetPasswordStep.tsx +97 -97
  68. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  69. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  70. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  71. package/src/components/ui/BetaBadge.tsx +58 -58
  72. package/src/components/ui/Footer.tsx +93 -93
  73. package/src/config/env.ts +57 -57
  74. package/src/config/logger.ts +62 -62
  75. package/src/config/vibe-log-transport.ts +264 -264
  76. package/src/hooks/useAuth.ts +117 -117
  77. package/src/hooks/useAuthSettings.ts +97 -97
  78. package/src/hooks/useAvailableProviders.ts +120 -120
  79. package/src/hooks/usePasswordValidation.ts +127 -127
  80. package/src/hooks/usePublicAuthSettings.ts +149 -149
  81. package/src/hooks/useSessionExpiration.ts +101 -101
  82. package/src/hooks/useViabilitySession.ts +336 -336
  83. package/src/index.ts +60 -60
  84. package/src/lib/api-handler.ts +643 -643
  85. package/src/lib/app-slug.ts +178 -178
  86. package/src/lib/ensure-fresh-access-token.ts +358 -335
  87. package/src/lib/geolocation.ts +265 -265
  88. package/src/lib/idp-fetch.ts +26 -10
  89. package/src/lib/jwt-decode.ts +82 -82
  90. package/src/lib/roles.ts +177 -177
  91. package/src/lib/session-store.ts +4 -1
  92. package/src/lib/site-logger.ts +245 -245
  93. package/src/lib/standardized-client-api.ts +901 -901
  94. package/src/lib/test-aware-get-token.ts +41 -41
  95. package/src/lib/user-agent-parser.ts +252 -252
  96. package/src/logging/api/admin-analytics.ts +51 -51
  97. package/src/logging/api/audit-log.ts +53 -53
  98. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  99. package/src/logging/components/AuditLogViewer.tsx +125 -125
  100. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  101. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  102. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  103. package/src/logging/hooks/useAuditLog.ts +24 -24
  104. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  105. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  106. package/src/logging/index.ts +18 -18
  107. package/src/logging/types/analytics.ts +81 -81
  108. package/src/logging/types/audit.ts +31 -31
  109. package/src/logging/types/index.ts +3 -3
  110. package/src/middleware/create-middleware.ts +663 -663
  111. package/src/middleware/rbac-check.ts +279 -279
  112. package/src/middleware/twofa-presets.ts +224 -224
  113. package/src/pages/admin-login/page.tsx +221 -221
  114. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  115. package/src/pages/admin-page-permissions/index.ts +7 -7
  116. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  117. package/src/pages/admin-roles/modals.tsx +469 -469
  118. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  119. package/src/pages/client-admin/index.ts +33 -33
  120. package/src/pages/coming-soon/page.tsx +83 -83
  121. package/src/pages/login/page.tsx +456 -456
  122. package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
  123. package/src/pages/profile/index.ts +9 -9
  124. package/src/pages/profile/page.tsx +166 -166
  125. package/src/pages/recovery/page.tsx +234 -234
  126. package/src/pages/roles/MyRolesPage.tsx +211 -211
  127. package/src/pages/roles/components.tsx +294 -294
  128. package/src/pages/roles/index.ts +17 -17
  129. package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
  130. package/src/pages/security/index.ts +9 -9
  131. package/src/pages/security/page.tsx +507 -507
  132. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  133. package/src/pages/settings/index.ts +9 -9
  134. package/src/pages/settings/page.tsx +47 -47
  135. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  136. package/src/pages/showcase/index.ts +13 -13
  137. package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
  138. package/src/pages/test-env/JwtInspectPage.tsx +456 -456
  139. package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
  140. package/src/pages/test-env/TestEnvPage.tsx +118 -118
  141. package/src/pages/test-env/index.ts +25 -25
  142. package/src/pages/verify-code/page.tsx +652 -652
  143. package/src/routes/account/update-phone.ts +13 -13
  144. package/src/routes/auth/logout.ts +108 -108
  145. package/src/routes/auth/nextauth.ts +45 -45
  146. package/src/routes/auth/settings.ts +75 -75
  147. package/src/routes/auth/viability.ts +224 -224
  148. package/src/server/auth-guard.ts +170 -170
  149. package/src/server/slim-middleware.ts +117 -117
  150. package/src/server/with-auth.ts +93 -93
  151. package/src/stores/authStore.ts +1899 -1899
  152. package/src/templates/instrumentation.ts +41 -41
  153. package/src/utils/logout.ts +30 -30
  154. package/src/vibe/client.ts +590 -590
  155. package/src/vibe/enterprise-auth.ts +208 -208
  156. package/src/vibe/errors.ts +185 -185
  157. package/src/vibe/generic.ts +431 -431
  158. package/src/vibe/hooks/index.ts +367 -367
  159. package/src/vibe/index.ts +132 -132
  160. package/src/vibe/sessions.ts +551 -551
  161. 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
  }