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