@payez/next-mvp 4.1.8 → 4.1.9

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