@payez/next-mvp 4.1.9 → 4.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +792 -792
- package/dist/api-handlers/auth/refresh.js +38 -15
- package/dist/auth/better-auth.js +7 -18
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/ensure-fresh-access-token.js +30 -7
- package/dist/lib/idp-fetch.js +25 -9
- package/dist/lib/session-store.d.ts +1 -0
- package/dist/lib/session-store.js +12 -8
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/package.json +893 -893
- package/scripts/dev-broker.ps1 +35 -35
- package/scripts/dev-local.ps1 +45 -45
- package/src/api/auth-handler.ts +548 -548
- package/src/api-handlers/account/change-password.ts +142 -142
- package/src/api-handlers/account/update-phone.ts +79 -79
- package/src/api-handlers/account/verify-email.ts +125 -125
- package/src/api-handlers/account/verify-sms.ts +125 -125
- package/src/api-handlers/admin/analytics.ts +443 -443
- package/src/api-handlers/admin/audit.ts +223 -223
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +251 -251
- package/src/api-handlers/admin/sessions.ts +318 -318
- package/src/api-handlers/admin/site-logs.ts +365 -365
- package/src/api-handlers/admin/users.ts +242 -242
- package/src/api-handlers/admin/vibe-data.ts +324 -324
- package/src/api-handlers/auth/refresh.ts +705 -682
- package/src/api-handlers/auth/signout.ts +199 -199
- package/src/api-handlers/auth/status.ts +20 -20
- package/src/api-handlers/auth/update-session.ts +110 -110
- package/src/api-handlers/auth/verify-code.ts +112 -112
- package/src/api-handlers/session/refresh-viability.ts +36 -36
- package/src/api-handlers/test/force-expire.ts +60 -60
- package/src/auth/auth-decision.ts +229 -229
- package/src/auth/better-auth.ts +7 -18
- package/src/auth/route-config.ts +219 -219
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/utils/idp-client.ts +445 -445
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +143 -143
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +266 -266
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +505 -505
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +414 -414
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/FederatedAuthSection.tsx +95 -95
- package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
- package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
- package/src/components/auth/TraditionalAuthSection.tsx +99 -99
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +97 -97
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +57 -57
- package/src/config/logger.ts +62 -62
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +97 -97
- package/src/hooks/useAvailableProviders.ts +120 -120
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/usePublicAuthSettings.ts +149 -149
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +336 -336
- package/src/index.ts +60 -60
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +178 -178
- package/src/lib/ensure-fresh-access-token.ts +358 -335
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-fetch.ts +26 -10
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/roles.ts +177 -177
- package/src/lib/session-store.ts +4 -1
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +663 -663
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/pages/admin-login/page.tsx +221 -221
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +456 -456
- package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +234 -234
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
- package/src/pages/security/index.ts +9 -9
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
- package/src/pages/test-env/JwtInspectPage.tsx +456 -456
- package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
- package/src/pages/test-env/TestEnvPage.tsx +118 -118
- package/src/pages/test-env/index.ts +25 -25
- package/src/pages/verify-code/page.tsx +652 -652
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/auth/logout.ts +108 -108
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/settings.ts +75 -75
- package/src/routes/auth/viability.ts +224 -224
- package/src/server/auth-guard.ts +170 -170
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/with-auth.ts +93 -93
- package/src/stores/authStore.ts +1899 -1899
- package/src/templates/instrumentation.ts +41 -41
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +590 -590
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +185 -185
- package/src/vibe/generic.ts +431 -431
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +132 -132
- package/src/vibe/sessions.ts +551 -551
- package/src/vibe/types.ts +577 -577
package/src/lib/api-handler.ts
CHANGED
|
@@ -1,643 +1,643 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified API Handler for PayEz Next.js Applications
|
|
3
|
-
*
|
|
4
|
-
* Provides a single, comprehensive API handler with:
|
|
5
|
-
* - Coordinated token refresh with Redis locks
|
|
6
|
-
* - Role-based access control
|
|
7
|
-
* - Middleware queue support
|
|
8
|
-
* - Timeout handling
|
|
9
|
-
* - Standardized PayEz API response format
|
|
10
|
-
* - Client slug validation (security)
|
|
11
|
-
*
|
|
12
|
-
* @version 1.0.0
|
|
13
|
-
* @since Consolidated from website-membership simple-api-handler + MVP auth-handler
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
17
|
-
import { nanoid } from 'nanoid';
|
|
18
|
-
import {
|
|
19
|
-
getSession,
|
|
20
|
-
getSessionWithVersion,
|
|
21
|
-
acquireRefreshLock,
|
|
22
|
-
releaseRefreshLock,
|
|
23
|
-
checkRefreshLock,
|
|
24
|
-
type RefreshLockInfo
|
|
25
|
-
} from './session-store';
|
|
26
|
-
import { getJwtCookieName } from './app-slug';
|
|
27
|
-
import { getTokenTestAware } from './test-aware-get-token';
|
|
28
|
-
import { internalRefresh } from './internal-api';
|
|
29
|
-
|
|
30
|
-
// ============================================================================
|
|
31
|
-
// Types
|
|
32
|
-
// ============================================================================
|
|
33
|
-
|
|
34
|
-
export interface ApiHandlerConfig {
|
|
35
|
-
/** Whether authentication is required (default: true) */
|
|
36
|
-
requireAuth?: boolean;
|
|
37
|
-
/** Roles required to access this endpoint */
|
|
38
|
-
requiredRoles?: string[];
|
|
39
|
-
/** Request timeout in milliseconds (default: 30000) */
|
|
40
|
-
timeout?: number;
|
|
41
|
-
/** Validate client_slug in token matches expected (default: true) */
|
|
42
|
-
validateClientSlug?: boolean;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ApiRequestContext {
|
|
46
|
-
requestId: string;
|
|
47
|
-
startTime: number;
|
|
48
|
-
endpoint: string;
|
|
49
|
-
method: string;
|
|
50
|
-
userId?: string;
|
|
51
|
-
sessionId?: string;
|
|
52
|
-
userAgent?: string;
|
|
53
|
-
ipAddress?: string;
|
|
54
|
-
params?: Record<string, string>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface ApiAuthContext {
|
|
58
|
-
accessToken: string | null;
|
|
59
|
-
userId?: string;
|
|
60
|
-
roles: string[];
|
|
61
|
-
sessionId?: string;
|
|
62
|
-
tokenType: string;
|
|
63
|
-
refreshToken: string | null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface ApiMiddleware {
|
|
67
|
-
name: string;
|
|
68
|
-
execute: (context: ApiRequestContext, config: ApiHandlerConfig) => Promise<void>;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export type ApiHandlerFunction<T = any> = (
|
|
72
|
-
req: NextRequest,
|
|
73
|
-
ctx: ApiRequestContext,
|
|
74
|
-
auth: ApiAuthContext,
|
|
75
|
-
responseBuilder: any
|
|
76
|
-
) => Promise<T>;
|
|
77
|
-
|
|
78
|
-
// ============================================================================
|
|
79
|
-
// API Response Builder (for backward compatibility)
|
|
80
|
-
// ============================================================================
|
|
81
|
-
|
|
82
|
-
export class ApiResponseBuilder {
|
|
83
|
-
private context: ApiRequestContext;
|
|
84
|
-
|
|
85
|
-
constructor(context: ApiRequestContext) {
|
|
86
|
-
this.context = context;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
success<T>(data: T, meta?: { operation?: string; version?: string; [key: string]: any }): { success: true; data: T; message: string; operation_code: string; meta: any; timestamp: string; request_id: string } {
|
|
90
|
-
return {
|
|
91
|
-
success: true,
|
|
92
|
-
data,
|
|
93
|
-
message: meta?.operation ? `${meta.operation.replace(/-/g, ' ')} completed successfully` : 'Operation completed successfully',
|
|
94
|
-
operation_code: meta?.operation || 'unknown-operation',
|
|
95
|
-
meta: {
|
|
96
|
-
version: '1.0',
|
|
97
|
-
responseTime: Date.now() - this.context.startTime,
|
|
98
|
-
...meta
|
|
99
|
-
},
|
|
100
|
-
timestamp: new Date().toISOString(),
|
|
101
|
-
request_id: this.context.requestId
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
error(code: string, message?: string, details?: Record<string, any>): { success: false; error: any; meta: any; timestamp: string; request_id: string } {
|
|
106
|
-
return {
|
|
107
|
-
success: false,
|
|
108
|
-
error: {
|
|
109
|
-
code,
|
|
110
|
-
message: message || code,
|
|
111
|
-
details
|
|
112
|
-
},
|
|
113
|
-
meta: {
|
|
114
|
-
version: '1.0',
|
|
115
|
-
responseTime: Date.now() - this.context.startTime
|
|
116
|
-
},
|
|
117
|
-
timestamp: new Date().toISOString(),
|
|
118
|
-
request_id: this.context.requestId
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ============================================================================
|
|
124
|
-
// API Handler Class
|
|
125
|
-
// ============================================================================
|
|
126
|
-
|
|
127
|
-
export class ApiHandler {
|
|
128
|
-
private config: Required<ApiHandlerConfig>;
|
|
129
|
-
private middlewareQueue: ApiMiddleware[] = [];
|
|
130
|
-
|
|
131
|
-
constructor(config: ApiHandlerConfig = {}) {
|
|
132
|
-
this.config = {
|
|
133
|
-
requireAuth: config.requireAuth ?? true,
|
|
134
|
-
requiredRoles: config.requiredRoles ?? [],
|
|
135
|
-
timeout: config.timeout ?? 30000,
|
|
136
|
-
validateClientSlug: config.validateClientSlug ?? true,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Add middleware to the handler chain
|
|
142
|
-
*/
|
|
143
|
-
use(middleware: ApiMiddleware): this {
|
|
144
|
-
this.middlewareQueue.push(middleware);
|
|
145
|
-
return this;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Create the route handler
|
|
150
|
-
*/
|
|
151
|
-
handle<T = any>(handler: ApiHandlerFunction<T>) {
|
|
152
|
-
return async (req: NextRequest): Promise<NextResponse> => {
|
|
153
|
-
const startTime = Date.now();
|
|
154
|
-
const requestId = nanoid();
|
|
155
|
-
|
|
156
|
-
const ctx: ApiRequestContext = {
|
|
157
|
-
requestId,
|
|
158
|
-
startTime,
|
|
159
|
-
endpoint: req.nextUrl.pathname,
|
|
160
|
-
method: req.method,
|
|
161
|
-
userAgent: req.headers.get('user-agent') || undefined,
|
|
162
|
-
ipAddress: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || undefined,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
// Execute middleware chain
|
|
167
|
-
for (const middleware of this.middlewareQueue) {
|
|
168
|
-
await middleware.execute(ctx, this.config);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Handle authentication
|
|
172
|
-
const authResult = await this.handleAuthentication(req, ctx);
|
|
173
|
-
|
|
174
|
-
if (!authResult.success) {
|
|
175
|
-
if (authResult.reason === 'REFRESH_IN_PROGRESS') {
|
|
176
|
-
const response = this.errorResponse('SERVICE_UNAVAILABLE', 'Token refresh in progress', 503, requestId);
|
|
177
|
-
response.headers.set('Retry-After', '1');
|
|
178
|
-
return response;
|
|
179
|
-
}
|
|
180
|
-
if (this.config.requireAuth) {
|
|
181
|
-
return this.errorResponse('UNAUTHORIZED', 'Authentication required', 401, requestId);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const auth = authResult.auth || this.createEmptyAuth();
|
|
186
|
-
|
|
187
|
-
// Attach auth context to request for downstream use
|
|
188
|
-
if (auth.accessToken) {
|
|
189
|
-
(req as any).__authContext = { accessToken: auth.accessToken };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Check roles
|
|
193
|
-
if (this.config.requiredRoles.length > 0 && auth) {
|
|
194
|
-
const hasRequiredRole = this.config.requiredRoles.some(role => auth.roles.includes(role));
|
|
195
|
-
if (!hasRequiredRole) {
|
|
196
|
-
return this.errorResponse('FORBIDDEN', 'Insufficient permissions', 403, requestId);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Create response builder for backward compatibility
|
|
201
|
-
const responseBuilder = new ApiResponseBuilder(ctx);
|
|
202
|
-
|
|
203
|
-
// Execute handler with timeout
|
|
204
|
-
const result = await this.executeWithTimeout(
|
|
205
|
-
handler(req, ctx, auth, responseBuilder),
|
|
206
|
-
this.config.timeout
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
return this.successResponse(result, requestId, ctx);
|
|
210
|
-
|
|
211
|
-
} catch (error) {
|
|
212
|
-
if (error instanceof Error && error.message === 'Request timeout') {
|
|
213
|
-
return this.errorResponse('TIMEOUT', 'Request timeout', 408, requestId);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const statusCode = error instanceof Error ? this.getStatusFromError(error) : 500;
|
|
217
|
-
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
218
|
-
|
|
219
|
-
return this.errorResponse(
|
|
220
|
-
this.getErrorCode(statusCode),
|
|
221
|
-
message,
|
|
222
|
-
statusCode,
|
|
223
|
-
requestId
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ==========================================================================
|
|
230
|
-
// Authentication
|
|
231
|
-
// ==========================================================================
|
|
232
|
-
|
|
233
|
-
private async handleAuthentication(
|
|
234
|
-
req: NextRequest,
|
|
235
|
-
ctx: ApiRequestContext
|
|
236
|
-
): Promise<{ success: true; auth: ApiAuthContext } | { success: false; reason: string; auth?: null }> {
|
|
237
|
-
try {
|
|
238
|
-
const token = await getTokenTestAware(req);
|
|
239
|
-
|
|
240
|
-
if (!token) {
|
|
241
|
-
if (this.config.requireAuth) {
|
|
242
|
-
return { success: false, reason: 'NO_TOKEN' };
|
|
243
|
-
}
|
|
244
|
-
return { success: true, auth: this.createEmptyAuth() };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Client slug validation (security feature from auth-handler)
|
|
248
|
-
if (this.config.validateClientSlug) {
|
|
249
|
-
const validationResult = this.validateClientSlug(token);
|
|
250
|
-
if (!validationResult.valid) {
|
|
251
|
-
return { success: false, reason: validationResult.reason };
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
|
|
256
|
-
const sessionToken = token.sessionToken || token.redisSessionId;
|
|
257
|
-
|
|
258
|
-
if (!sessionToken) {
|
|
259
|
-
return this.handleNoSessionToken(token, ctx);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Get session with version for coordinated refresh
|
|
263
|
-
const sessionWithVersion = await getSessionWithVersion(sessionToken);
|
|
264
|
-
|
|
265
|
-
if (!sessionWithVersion) {
|
|
266
|
-
return { success: false, reason: 'SESSION_EXPIRED' };
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const { session: sessionData } = sessionWithVersion;
|
|
270
|
-
// Sessions may store the access token under either field name
|
|
271
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
272
|
-
let accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken || null;
|
|
273
|
-
let userRoles: string[] = Array.isArray(sessionData.roles) ? sessionData.roles : [];
|
|
274
|
-
|
|
275
|
-
// Merge roles from JWT token
|
|
276
|
-
try {
|
|
277
|
-
const tokenRoles: string[] = Array.isArray(token.roles) ? token.roles : [];
|
|
278
|
-
if (tokenRoles.length > 0) {
|
|
279
|
-
userRoles = Array.from(new Set([...userRoles, ...tokenRoles]));
|
|
280
|
-
}
|
|
281
|
-
} catch { /* ignore */ }
|
|
282
|
-
|
|
283
|
-
// Check if token needs refresh.
|
|
284
|
-
// Skip the optimization (hasRefreshToken check) when access token is missing —
|
|
285
|
-
// some session shapes store the refresh token under different field names,
|
|
286
|
-
// and we still want a refresh attempt to populate the access token.
|
|
287
|
-
const thresholdMs = 5 * 60 * 1000;
|
|
288
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
289
|
-
const expires = sessionData.idpAccessTokenExpires || (sessionData as any).accessTokenExpires || 0;
|
|
290
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
291
|
-
const hasRefreshToken = !!(sessionData.idpRefreshToken || (sessionData as any).refreshToken);
|
|
292
|
-
const accessTokenStale = !accessToken || (expires - Date.now()) <= thresholdMs;
|
|
293
|
-
// If we already have a fresh access token, skip refresh entirely (no lock).
|
|
294
|
-
// If we don't, only attempt refresh when we have a refresh token to use.
|
|
295
|
-
const needsRefresh = accessTokenStale && hasRefreshToken;
|
|
296
|
-
|
|
297
|
-
if (needsRefresh) {
|
|
298
|
-
const refreshResult = await this.handleCoordinatedRefresh(req, token, sessionData, ctx);
|
|
299
|
-
if (refreshResult.blocked) {
|
|
300
|
-
return { success: false, reason: 'REFRESH_IN_PROGRESS' };
|
|
301
|
-
}
|
|
302
|
-
if (refreshResult.accessToken) {
|
|
303
|
-
accessToken = refreshResult.accessToken;
|
|
304
|
-
if (refreshResult.roles) {
|
|
305
|
-
userRoles = Array.from(new Set([...userRoles, ...refreshResult.roles]));
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (!accessToken) {
|
|
311
|
-
return { success: false, reason: 'NO_ACCESS_TOKEN' };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
ctx.userId = token.sub;
|
|
315
|
-
ctx.sessionId = sessionToken;
|
|
316
|
-
|
|
317
|
-
return {
|
|
318
|
-
success: true,
|
|
319
|
-
auth: {
|
|
320
|
-
accessToken,
|
|
321
|
-
userId: token.sub,
|
|
322
|
-
roles: userRoles,
|
|
323
|
-
sessionId: sessionToken,
|
|
324
|
-
tokenType: 'bearer',
|
|
325
|
-
refreshToken: sessionData?.refreshToken || null,
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
} catch (error) {
|
|
330
|
-
return { success: false, reason: 'AUTH_ERROR' };
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
private validateClientSlug(token: any): { valid: boolean; reason: string } {
|
|
335
|
-
const expectedClientSlug = process.env.NEXT_PUBLIC_EXPECTED_CLIENT_SLUG;
|
|
336
|
-
|
|
337
|
-
// If not configured, skip validation (backward compat)
|
|
338
|
-
if (!expectedClientSlug) {
|
|
339
|
-
return { valid: true, reason: '' };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const tokenClientSlug = token.client_slug || token.clientSlug;
|
|
343
|
-
|
|
344
|
-
if (!tokenClientSlug) {
|
|
345
|
-
return { valid: false, reason: 'TOKEN_MISSING_CLIENT_SLUG' };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (tokenClientSlug.toLowerCase() !== expectedClientSlug.toLowerCase()) {
|
|
349
|
-
return { valid: false, reason: 'TOKEN_CLIENT_MISMATCH' };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return { valid: true, reason: '' };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private handleNoSessionToken(token: any, ctx: ApiRequestContext): { success: true; auth: ApiAuthContext } | { success: false; reason: string } {
|
|
356
|
-
const allowTestFallback = process.env.TEST_MODE === 'true' || process.env.NODE_ENV === 'test';
|
|
357
|
-
|
|
358
|
-
if (allowTestFallback && token.accessToken) {
|
|
359
|
-
return {
|
|
360
|
-
success: true,
|
|
361
|
-
auth: {
|
|
362
|
-
accessToken: token.accessToken,
|
|
363
|
-
userId: token.sub,
|
|
364
|
-
roles: token.roles || [],
|
|
365
|
-
sessionId: undefined,
|
|
366
|
-
tokenType: 'bearer',
|
|
367
|
-
refreshToken: null,
|
|
368
|
-
}
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (this.config.requireAuth) {
|
|
373
|
-
return { success: false, reason: 'NO_SESSION_TOKEN' };
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
return { success: true, auth: this.createEmptyAuth() };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// ==========================================================================
|
|
380
|
-
// Coordinated Refresh
|
|
381
|
-
// ==========================================================================
|
|
382
|
-
|
|
383
|
-
private async handleCoordinatedRefresh(
|
|
384
|
-
req: NextRequest,
|
|
385
|
-
token: any,
|
|
386
|
-
sessionData: any,
|
|
387
|
-
ctx: ApiRequestContext
|
|
388
|
-
): Promise<{ blocked?: boolean; accessToken?: string; roles?: string[] }> {
|
|
389
|
-
// Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
|
|
390
|
-
const sessionToken = token.sessionToken || token.redisSessionId;
|
|
391
|
-
|
|
392
|
-
const existingLock = await checkRefreshLock(sessionToken);
|
|
393
|
-
|
|
394
|
-
if (existingLock) {
|
|
395
|
-
// Wait for existing refresh to complete
|
|
396
|
-
const waitResult = await this.waitForRefresh(sessionToken, 10000);
|
|
397
|
-
if (waitResult.success) {
|
|
398
|
-
const refreshed = await getSession(sessionToken);
|
|
399
|
-
if (refreshed?.accessToken) {
|
|
400
|
-
return {
|
|
401
|
-
accessToken: refreshed.accessToken,
|
|
402
|
-
roles: Array.isArray(refreshed.roles) ? refreshed.roles : [],
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return { blocked: true };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Try to acquire lock
|
|
410
|
-
const lockResult = await acquireRefreshLock(sessionToken, ctx.requestId, 5000);
|
|
411
|
-
|
|
412
|
-
if (!lockResult.acquired || !lockResult.lockInfo) {
|
|
413
|
-
const waitResult = await this.waitForRefresh(sessionToken, 10000);
|
|
414
|
-
if (waitResult.success) {
|
|
415
|
-
const refreshed = await getSession(sessionToken);
|
|
416
|
-
if (refreshed?.accessToken) {
|
|
417
|
-
return {
|
|
418
|
-
accessToken: refreshed.accessToken,
|
|
419
|
-
roles: Array.isArray(refreshed.roles) ? refreshed.roles : [],
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
return { blocked: true };
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
try {
|
|
427
|
-
// Double-check if still needs refresh
|
|
428
|
-
const latest = await getSession(sessionToken);
|
|
429
|
-
const thresholdMs = 5 * 60 * 1000;
|
|
430
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
431
|
-
const latestAny = latest as any;
|
|
432
|
-
// Sessions may store the access token under either `accessToken` or `idpAccessToken`,
|
|
433
|
-
// and expiry under `accessTokenExpires` or `idpAccessTokenExpires`.
|
|
434
|
-
const latestAccessToken = latestAny?.accessToken || latestAny?.idpAccessToken;
|
|
435
|
-
const latestExpires = latestAny?.accessTokenExpires || latestAny?.idpAccessTokenExpires || 0;
|
|
436
|
-
const stillNeeds = !latestAccessToken || (latestExpires - Date.now()) <= thresholdMs;
|
|
437
|
-
|
|
438
|
-
if (!stillNeeds && latestAccessToken) {
|
|
439
|
-
return {
|
|
440
|
-
accessToken: latestAccessToken,
|
|
441
|
-
roles: Array.isArray(latest?.roles) ? latest.roles : [],
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Use centralized internal API helper for server-to-server refresh calls
|
|
446
|
-
const refreshResponse = await internalRefresh(
|
|
447
|
-
req.headers.get('cookie') || '',
|
|
448
|
-
sessionToken,
|
|
449
|
-
sessionData.idpRefreshToken,
|
|
450
|
-
ctx.requestId
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
if (!refreshResponse.ok) {
|
|
454
|
-
return {};
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const refreshed = await getSession(sessionToken);
|
|
458
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
459
|
-
const refreshedAccessToken = (refreshed as any)?.accessToken || (refreshed as any)?.idpAccessToken;
|
|
460
|
-
if (refreshedAccessToken) {
|
|
461
|
-
return {
|
|
462
|
-
accessToken: refreshedAccessToken,
|
|
463
|
-
roles: Array.isArray(refreshed?.roles) ? refreshed.roles : [],
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return {};
|
|
468
|
-
|
|
469
|
-
} finally {
|
|
470
|
-
await releaseRefreshLock(sessionToken, ctx.requestId, lockResult.lockInfo.lockVersion);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
private async waitForRefresh(sessionToken: string, maxWaitMs: number): Promise<{ success: boolean }> {
|
|
475
|
-
const startTime = Date.now();
|
|
476
|
-
const pollInterval = 100;
|
|
477
|
-
|
|
478
|
-
while (Date.now() - startTime < maxWaitMs) {
|
|
479
|
-
const lockExists = await checkRefreshLock(sessionToken);
|
|
480
|
-
if (!lockExists) {
|
|
481
|
-
const session = await getSession(sessionToken);
|
|
482
|
-
if (session?.accessToken) {
|
|
483
|
-
const expires = session.accessTokenExpires || 0;
|
|
484
|
-
if ((expires - Date.now()) > (5 * 60 * 1000)) {
|
|
485
|
-
return { success: true };
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return { success: false };
|
|
489
|
-
}
|
|
490
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
return { success: false };
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// ==========================================================================
|
|
497
|
-
// Response Helpers
|
|
498
|
-
// ==========================================================================
|
|
499
|
-
|
|
500
|
-
private createEmptyAuth(): ApiAuthContext {
|
|
501
|
-
return {
|
|
502
|
-
accessToken: null,
|
|
503
|
-
roles: [],
|
|
504
|
-
tokenType: 'bearer',
|
|
505
|
-
refreshToken: null,
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
private async executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
510
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
511
|
-
setTimeout(() => reject(new Error('Request timeout')), timeoutMs);
|
|
512
|
-
});
|
|
513
|
-
return Promise.race([promise, timeoutPromise]);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
private successResponse(data: any, requestId: string, ctx: ApiRequestContext): NextResponse {
|
|
517
|
-
// Pass through Response objects unchanged
|
|
518
|
-
if (data instanceof Response) {
|
|
519
|
-
return data as any;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Handle nested success/error flags
|
|
523
|
-
if (data && typeof data === 'object') {
|
|
524
|
-
if (Object.prototype.hasOwnProperty.call(data, 'success')) {
|
|
525
|
-
if (data.success === false) {
|
|
526
|
-
const msg = data.error?.message || data.message || 'Upstream service error';
|
|
527
|
-
const code = data.error?.code || 'UPSTREAM_SERVICE_ERROR';
|
|
528
|
-
return this.errorResponse(code, msg, 400, requestId);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
return NextResponse.json({
|
|
534
|
-
success: true,
|
|
535
|
-
data,
|
|
536
|
-
message: 'Operation completed successfully',
|
|
537
|
-
operation_code: 'api_handler_operation',
|
|
538
|
-
timestamp: new Date().toISOString(),
|
|
539
|
-
request_id: requestId,
|
|
540
|
-
meta: {
|
|
541
|
-
version: '1.0',
|
|
542
|
-
responseTime: Date.now() - ctx.startTime,
|
|
543
|
-
}
|
|
544
|
-
}, { status: 200 });
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
private errorResponse(code: string, message: string, status: number, requestId: string): NextResponse {
|
|
548
|
-
const now = new Date().toISOString();
|
|
549
|
-
return NextResponse.json({
|
|
550
|
-
success: false,
|
|
551
|
-
message,
|
|
552
|
-
operation_code: 'api_handler_error',
|
|
553
|
-
timestamp: now,
|
|
554
|
-
request_id: requestId,
|
|
555
|
-
error: {
|
|
556
|
-
code,
|
|
557
|
-
message,
|
|
558
|
-
support: { request_id: requestId, timestamp: now }
|
|
559
|
-
},
|
|
560
|
-
meta: { version: '1.0' }
|
|
561
|
-
}, { status });
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
private getStatusFromError(error: Error): number {
|
|
565
|
-
const msg = error.message.toLowerCase();
|
|
566
|
-
|
|
567
|
-
if (msg.includes('unauthorized')) return 401;
|
|
568
|
-
if (msg.includes('forbidden') || msg.includes('insufficient permissions')) return 403;
|
|
569
|
-
if (msg.includes('not found')) return 404;
|
|
570
|
-
if (msg.includes('timeout')) return 408;
|
|
571
|
-
if (msg.includes('rate limit')) return 429;
|
|
572
|
-
if (msg.includes('bad gateway') || msg.includes('upstream')) return 502;
|
|
573
|
-
if (msg.includes('service unavailable')) return 503;
|
|
574
|
-
|
|
575
|
-
return 500;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
private getErrorCode(status: number): string {
|
|
579
|
-
switch (status) {
|
|
580
|
-
case 400: return 'BAD_REQUEST';
|
|
581
|
-
case 401: return 'UNAUTHORIZED';
|
|
582
|
-
case 403: return 'FORBIDDEN';
|
|
583
|
-
case 404: return 'NOT_FOUND';
|
|
584
|
-
case 408: return 'TIMEOUT';
|
|
585
|
-
case 429: return 'RATE_LIMIT_EXCEEDED';
|
|
586
|
-
case 502: return 'BAD_GATEWAY';
|
|
587
|
-
case 503: return 'SERVICE_UNAVAILABLE';
|
|
588
|
-
default: return 'INTERNAL_SERVER_ERROR';
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// ============================================================================
|
|
594
|
-
// Factory Function
|
|
595
|
-
// ============================================================================
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Create an API handler with the specified configuration
|
|
599
|
-
*
|
|
600
|
-
* @example
|
|
601
|
-
* ```typescript
|
|
602
|
-
* export const GET = createApiHandler({ requireAuth: true })
|
|
603
|
-
* .handle(async (req, ctx, auth) => {
|
|
604
|
-
* return { data: 'hello' };
|
|
605
|
-
* });
|
|
606
|
-
* ```
|
|
607
|
-
*/
|
|
608
|
-
export function createApiHandler(config: ApiHandlerConfig = {}): ApiHandler {
|
|
609
|
-
return new ApiHandler(config);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// ============================================================================
|
|
613
|
-
// Pre-configured Presets
|
|
614
|
-
// ============================================================================
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Pre-configured API handler presets for common use cases
|
|
618
|
-
*/
|
|
619
|
-
export const API_PRESETS = {
|
|
620
|
-
/** Public endpoint - no authentication required */
|
|
621
|
-
PUBLIC: () => createApiHandler({ requireAuth: false, validateClientSlug: false }),
|
|
622
|
-
|
|
623
|
-
/** Authenticated endpoint - any logged-in user */
|
|
624
|
-
AUTHENTICATED: () => createApiHandler({ requireAuth: true }),
|
|
625
|
-
|
|
626
|
-
/** User endpoint - requires payez_user or payez_admin role */
|
|
627
|
-
USER: () => createApiHandler({
|
|
628
|
-
requireAuth: true,
|
|
629
|
-
requiredRoles: ['payez_user', 'payez_admin'],
|
|
630
|
-
}),
|
|
631
|
-
|
|
632
|
-
/** Admin endpoint - requires payez_admin role */
|
|
633
|
-
ADMIN: () => createApiHandler({
|
|
634
|
-
requireAuth: true,
|
|
635
|
-
requiredRoles: ['payez_admin'],
|
|
636
|
-
}),
|
|
637
|
-
} as const;
|
|
638
|
-
|
|
639
|
-
// ============================================================================
|
|
640
|
-
// Default Export
|
|
641
|
-
// ============================================================================
|
|
642
|
-
|
|
643
|
-
export default createApiHandler;
|
|
1
|
+
/**
|
|
2
|
+
* Unified API Handler for PayEz Next.js Applications
|
|
3
|
+
*
|
|
4
|
+
* Provides a single, comprehensive API handler with:
|
|
5
|
+
* - Coordinated token refresh with Redis locks
|
|
6
|
+
* - Role-based access control
|
|
7
|
+
* - Middleware queue support
|
|
8
|
+
* - Timeout handling
|
|
9
|
+
* - Standardized PayEz API response format
|
|
10
|
+
* - Client slug validation (security)
|
|
11
|
+
*
|
|
12
|
+
* @version 1.0.0
|
|
13
|
+
* @since Consolidated from website-membership simple-api-handler + MVP auth-handler
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
17
|
+
import { nanoid } from 'nanoid';
|
|
18
|
+
import {
|
|
19
|
+
getSession,
|
|
20
|
+
getSessionWithVersion,
|
|
21
|
+
acquireRefreshLock,
|
|
22
|
+
releaseRefreshLock,
|
|
23
|
+
checkRefreshLock,
|
|
24
|
+
type RefreshLockInfo
|
|
25
|
+
} from './session-store';
|
|
26
|
+
import { getJwtCookieName } from './app-slug';
|
|
27
|
+
import { getTokenTestAware } from './test-aware-get-token';
|
|
28
|
+
import { internalRefresh } from './internal-api';
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Types
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
export interface ApiHandlerConfig {
|
|
35
|
+
/** Whether authentication is required (default: true) */
|
|
36
|
+
requireAuth?: boolean;
|
|
37
|
+
/** Roles required to access this endpoint */
|
|
38
|
+
requiredRoles?: string[];
|
|
39
|
+
/** Request timeout in milliseconds (default: 30000) */
|
|
40
|
+
timeout?: number;
|
|
41
|
+
/** Validate client_slug in token matches expected (default: true) */
|
|
42
|
+
validateClientSlug?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ApiRequestContext {
|
|
46
|
+
requestId: string;
|
|
47
|
+
startTime: number;
|
|
48
|
+
endpoint: string;
|
|
49
|
+
method: string;
|
|
50
|
+
userId?: string;
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
userAgent?: string;
|
|
53
|
+
ipAddress?: string;
|
|
54
|
+
params?: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ApiAuthContext {
|
|
58
|
+
accessToken: string | null;
|
|
59
|
+
userId?: string;
|
|
60
|
+
roles: string[];
|
|
61
|
+
sessionId?: string;
|
|
62
|
+
tokenType: string;
|
|
63
|
+
refreshToken: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ApiMiddleware {
|
|
67
|
+
name: string;
|
|
68
|
+
execute: (context: ApiRequestContext, config: ApiHandlerConfig) => Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ApiHandlerFunction<T = any> = (
|
|
72
|
+
req: NextRequest,
|
|
73
|
+
ctx: ApiRequestContext,
|
|
74
|
+
auth: ApiAuthContext,
|
|
75
|
+
responseBuilder: any
|
|
76
|
+
) => Promise<T>;
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// API Response Builder (for backward compatibility)
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
export class ApiResponseBuilder {
|
|
83
|
+
private context: ApiRequestContext;
|
|
84
|
+
|
|
85
|
+
constructor(context: ApiRequestContext) {
|
|
86
|
+
this.context = context;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
success<T>(data: T, meta?: { operation?: string; version?: string; [key: string]: any }): { success: true; data: T; message: string; operation_code: string; meta: any; timestamp: string; request_id: string } {
|
|
90
|
+
return {
|
|
91
|
+
success: true,
|
|
92
|
+
data,
|
|
93
|
+
message: meta?.operation ? `${meta.operation.replace(/-/g, ' ')} completed successfully` : 'Operation completed successfully',
|
|
94
|
+
operation_code: meta?.operation || 'unknown-operation',
|
|
95
|
+
meta: {
|
|
96
|
+
version: '1.0',
|
|
97
|
+
responseTime: Date.now() - this.context.startTime,
|
|
98
|
+
...meta
|
|
99
|
+
},
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
request_id: this.context.requestId
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
error(code: string, message?: string, details?: Record<string, any>): { success: false; error: any; meta: any; timestamp: string; request_id: string } {
|
|
106
|
+
return {
|
|
107
|
+
success: false,
|
|
108
|
+
error: {
|
|
109
|
+
code,
|
|
110
|
+
message: message || code,
|
|
111
|
+
details
|
|
112
|
+
},
|
|
113
|
+
meta: {
|
|
114
|
+
version: '1.0',
|
|
115
|
+
responseTime: Date.now() - this.context.startTime
|
|
116
|
+
},
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
request_id: this.context.requestId
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// API Handler Class
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
export class ApiHandler {
|
|
128
|
+
private config: Required<ApiHandlerConfig>;
|
|
129
|
+
private middlewareQueue: ApiMiddleware[] = [];
|
|
130
|
+
|
|
131
|
+
constructor(config: ApiHandlerConfig = {}) {
|
|
132
|
+
this.config = {
|
|
133
|
+
requireAuth: config.requireAuth ?? true,
|
|
134
|
+
requiredRoles: config.requiredRoles ?? [],
|
|
135
|
+
timeout: config.timeout ?? 30000,
|
|
136
|
+
validateClientSlug: config.validateClientSlug ?? true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Add middleware to the handler chain
|
|
142
|
+
*/
|
|
143
|
+
use(middleware: ApiMiddleware): this {
|
|
144
|
+
this.middlewareQueue.push(middleware);
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create the route handler
|
|
150
|
+
*/
|
|
151
|
+
handle<T = any>(handler: ApiHandlerFunction<T>) {
|
|
152
|
+
return async (req: NextRequest): Promise<NextResponse> => {
|
|
153
|
+
const startTime = Date.now();
|
|
154
|
+
const requestId = nanoid();
|
|
155
|
+
|
|
156
|
+
const ctx: ApiRequestContext = {
|
|
157
|
+
requestId,
|
|
158
|
+
startTime,
|
|
159
|
+
endpoint: req.nextUrl.pathname,
|
|
160
|
+
method: req.method,
|
|
161
|
+
userAgent: req.headers.get('user-agent') || undefined,
|
|
162
|
+
ipAddress: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || undefined,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// Execute middleware chain
|
|
167
|
+
for (const middleware of this.middlewareQueue) {
|
|
168
|
+
await middleware.execute(ctx, this.config);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Handle authentication
|
|
172
|
+
const authResult = await this.handleAuthentication(req, ctx);
|
|
173
|
+
|
|
174
|
+
if (!authResult.success) {
|
|
175
|
+
if (authResult.reason === 'REFRESH_IN_PROGRESS') {
|
|
176
|
+
const response = this.errorResponse('SERVICE_UNAVAILABLE', 'Token refresh in progress', 503, requestId);
|
|
177
|
+
response.headers.set('Retry-After', '1');
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
if (this.config.requireAuth) {
|
|
181
|
+
return this.errorResponse('UNAUTHORIZED', 'Authentication required', 401, requestId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const auth = authResult.auth || this.createEmptyAuth();
|
|
186
|
+
|
|
187
|
+
// Attach auth context to request for downstream use
|
|
188
|
+
if (auth.accessToken) {
|
|
189
|
+
(req as any).__authContext = { accessToken: auth.accessToken };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check roles
|
|
193
|
+
if (this.config.requiredRoles.length > 0 && auth) {
|
|
194
|
+
const hasRequiredRole = this.config.requiredRoles.some(role => auth.roles.includes(role));
|
|
195
|
+
if (!hasRequiredRole) {
|
|
196
|
+
return this.errorResponse('FORBIDDEN', 'Insufficient permissions', 403, requestId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Create response builder for backward compatibility
|
|
201
|
+
const responseBuilder = new ApiResponseBuilder(ctx);
|
|
202
|
+
|
|
203
|
+
// Execute handler with timeout
|
|
204
|
+
const result = await this.executeWithTimeout(
|
|
205
|
+
handler(req, ctx, auth, responseBuilder),
|
|
206
|
+
this.config.timeout
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return this.successResponse(result, requestId, ctx);
|
|
210
|
+
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof Error && error.message === 'Request timeout') {
|
|
213
|
+
return this.errorResponse('TIMEOUT', 'Request timeout', 408, requestId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const statusCode = error instanceof Error ? this.getStatusFromError(error) : 500;
|
|
217
|
+
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
218
|
+
|
|
219
|
+
return this.errorResponse(
|
|
220
|
+
this.getErrorCode(statusCode),
|
|
221
|
+
message,
|
|
222
|
+
statusCode,
|
|
223
|
+
requestId
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ==========================================================================
|
|
230
|
+
// Authentication
|
|
231
|
+
// ==========================================================================
|
|
232
|
+
|
|
233
|
+
private async handleAuthentication(
|
|
234
|
+
req: NextRequest,
|
|
235
|
+
ctx: ApiRequestContext
|
|
236
|
+
): Promise<{ success: true; auth: ApiAuthContext } | { success: false; reason: string; auth?: null }> {
|
|
237
|
+
try {
|
|
238
|
+
const token = await getTokenTestAware(req);
|
|
239
|
+
|
|
240
|
+
if (!token) {
|
|
241
|
+
if (this.config.requireAuth) {
|
|
242
|
+
return { success: false, reason: 'NO_TOKEN' };
|
|
243
|
+
}
|
|
244
|
+
return { success: true, auth: this.createEmptyAuth() };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Client slug validation (security feature from auth-handler)
|
|
248
|
+
if (this.config.validateClientSlug) {
|
|
249
|
+
const validationResult = this.validateClientSlug(token);
|
|
250
|
+
if (!validationResult.valid) {
|
|
251
|
+
return { success: false, reason: validationResult.reason };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
|
|
256
|
+
const sessionToken = token.sessionToken || token.redisSessionId;
|
|
257
|
+
|
|
258
|
+
if (!sessionToken) {
|
|
259
|
+
return this.handleNoSessionToken(token, ctx);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Get session with version for coordinated refresh
|
|
263
|
+
const sessionWithVersion = await getSessionWithVersion(sessionToken);
|
|
264
|
+
|
|
265
|
+
if (!sessionWithVersion) {
|
|
266
|
+
return { success: false, reason: 'SESSION_EXPIRED' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { session: sessionData } = sessionWithVersion;
|
|
270
|
+
// Sessions may store the access token under either field name
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
272
|
+
let accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken || null;
|
|
273
|
+
let userRoles: string[] = Array.isArray(sessionData.roles) ? sessionData.roles : [];
|
|
274
|
+
|
|
275
|
+
// Merge roles from JWT token
|
|
276
|
+
try {
|
|
277
|
+
const tokenRoles: string[] = Array.isArray(token.roles) ? token.roles : [];
|
|
278
|
+
if (tokenRoles.length > 0) {
|
|
279
|
+
userRoles = Array.from(new Set([...userRoles, ...tokenRoles]));
|
|
280
|
+
}
|
|
281
|
+
} catch { /* ignore */ }
|
|
282
|
+
|
|
283
|
+
// Check if token needs refresh.
|
|
284
|
+
// Skip the optimization (hasRefreshToken check) when access token is missing —
|
|
285
|
+
// some session shapes store the refresh token under different field names,
|
|
286
|
+
// and we still want a refresh attempt to populate the access token.
|
|
287
|
+
const thresholdMs = 5 * 60 * 1000;
|
|
288
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
289
|
+
const expires = sessionData.idpAccessTokenExpires || (sessionData as any).accessTokenExpires || 0;
|
|
290
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
291
|
+
const hasRefreshToken = !!(sessionData.idpRefreshToken || (sessionData as any).refreshToken);
|
|
292
|
+
const accessTokenStale = !accessToken || (expires - Date.now()) <= thresholdMs;
|
|
293
|
+
// If we already have a fresh access token, skip refresh entirely (no lock).
|
|
294
|
+
// If we don't, only attempt refresh when we have a refresh token to use.
|
|
295
|
+
const needsRefresh = accessTokenStale && hasRefreshToken;
|
|
296
|
+
|
|
297
|
+
if (needsRefresh) {
|
|
298
|
+
const refreshResult = await this.handleCoordinatedRefresh(req, token, sessionData, ctx);
|
|
299
|
+
if (refreshResult.blocked) {
|
|
300
|
+
return { success: false, reason: 'REFRESH_IN_PROGRESS' };
|
|
301
|
+
}
|
|
302
|
+
if (refreshResult.accessToken) {
|
|
303
|
+
accessToken = refreshResult.accessToken;
|
|
304
|
+
if (refreshResult.roles) {
|
|
305
|
+
userRoles = Array.from(new Set([...userRoles, ...refreshResult.roles]));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!accessToken) {
|
|
311
|
+
return { success: false, reason: 'NO_ACCESS_TOKEN' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
ctx.userId = token.sub;
|
|
315
|
+
ctx.sessionId = sessionToken;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
success: true,
|
|
319
|
+
auth: {
|
|
320
|
+
accessToken,
|
|
321
|
+
userId: token.sub,
|
|
322
|
+
roles: userRoles,
|
|
323
|
+
sessionId: sessionToken,
|
|
324
|
+
tokenType: 'bearer',
|
|
325
|
+
refreshToken: sessionData?.refreshToken || null,
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return { success: false, reason: 'AUTH_ERROR' };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private validateClientSlug(token: any): { valid: boolean; reason: string } {
|
|
335
|
+
const expectedClientSlug = process.env.NEXT_PUBLIC_EXPECTED_CLIENT_SLUG;
|
|
336
|
+
|
|
337
|
+
// If not configured, skip validation (backward compat)
|
|
338
|
+
if (!expectedClientSlug) {
|
|
339
|
+
return { valid: true, reason: '' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const tokenClientSlug = token.client_slug || token.clientSlug;
|
|
343
|
+
|
|
344
|
+
if (!tokenClientSlug) {
|
|
345
|
+
return { valid: false, reason: 'TOKEN_MISSING_CLIENT_SLUG' };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (tokenClientSlug.toLowerCase() !== expectedClientSlug.toLowerCase()) {
|
|
349
|
+
return { valid: false, reason: 'TOKEN_CLIENT_MISMATCH' };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { valid: true, reason: '' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private handleNoSessionToken(token: any, ctx: ApiRequestContext): { success: true; auth: ApiAuthContext } | { success: false; reason: string } {
|
|
356
|
+
const allowTestFallback = process.env.TEST_MODE === 'true' || process.env.NODE_ENV === 'test';
|
|
357
|
+
|
|
358
|
+
if (allowTestFallback && token.accessToken) {
|
|
359
|
+
return {
|
|
360
|
+
success: true,
|
|
361
|
+
auth: {
|
|
362
|
+
accessToken: token.accessToken,
|
|
363
|
+
userId: token.sub,
|
|
364
|
+
roles: token.roles || [],
|
|
365
|
+
sessionId: undefined,
|
|
366
|
+
tokenType: 'bearer',
|
|
367
|
+
refreshToken: null,
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (this.config.requireAuth) {
|
|
373
|
+
return { success: false, reason: 'NO_SESSION_TOKEN' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { success: true, auth: this.createEmptyAuth() };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ==========================================================================
|
|
380
|
+
// Coordinated Refresh
|
|
381
|
+
// ==========================================================================
|
|
382
|
+
|
|
383
|
+
private async handleCoordinatedRefresh(
|
|
384
|
+
req: NextRequest,
|
|
385
|
+
token: any,
|
|
386
|
+
sessionData: any,
|
|
387
|
+
ctx: ApiRequestContext
|
|
388
|
+
): Promise<{ blocked?: boolean; accessToken?: string; roles?: string[] }> {
|
|
389
|
+
// Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
|
|
390
|
+
const sessionToken = token.sessionToken || token.redisSessionId;
|
|
391
|
+
|
|
392
|
+
const existingLock = await checkRefreshLock(sessionToken);
|
|
393
|
+
|
|
394
|
+
if (existingLock) {
|
|
395
|
+
// Wait for existing refresh to complete
|
|
396
|
+
const waitResult = await this.waitForRefresh(sessionToken, 10000);
|
|
397
|
+
if (waitResult.success) {
|
|
398
|
+
const refreshed = await getSession(sessionToken);
|
|
399
|
+
if (refreshed?.accessToken) {
|
|
400
|
+
return {
|
|
401
|
+
accessToken: refreshed.accessToken,
|
|
402
|
+
roles: Array.isArray(refreshed.roles) ? refreshed.roles : [],
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { blocked: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Try to acquire lock
|
|
410
|
+
const lockResult = await acquireRefreshLock(sessionToken, ctx.requestId, 5000);
|
|
411
|
+
|
|
412
|
+
if (!lockResult.acquired || !lockResult.lockInfo) {
|
|
413
|
+
const waitResult = await this.waitForRefresh(sessionToken, 10000);
|
|
414
|
+
if (waitResult.success) {
|
|
415
|
+
const refreshed = await getSession(sessionToken);
|
|
416
|
+
if (refreshed?.accessToken) {
|
|
417
|
+
return {
|
|
418
|
+
accessToken: refreshed.accessToken,
|
|
419
|
+
roles: Array.isArray(refreshed.roles) ? refreshed.roles : [],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { blocked: true };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
// Double-check if still needs refresh
|
|
428
|
+
const latest = await getSession(sessionToken);
|
|
429
|
+
const thresholdMs = 5 * 60 * 1000;
|
|
430
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
431
|
+
const latestAny = latest as any;
|
|
432
|
+
// Sessions may store the access token under either `accessToken` or `idpAccessToken`,
|
|
433
|
+
// and expiry under `accessTokenExpires` or `idpAccessTokenExpires`.
|
|
434
|
+
const latestAccessToken = latestAny?.accessToken || latestAny?.idpAccessToken;
|
|
435
|
+
const latestExpires = latestAny?.accessTokenExpires || latestAny?.idpAccessTokenExpires || 0;
|
|
436
|
+
const stillNeeds = !latestAccessToken || (latestExpires - Date.now()) <= thresholdMs;
|
|
437
|
+
|
|
438
|
+
if (!stillNeeds && latestAccessToken) {
|
|
439
|
+
return {
|
|
440
|
+
accessToken: latestAccessToken,
|
|
441
|
+
roles: Array.isArray(latest?.roles) ? latest.roles : [],
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Use centralized internal API helper for server-to-server refresh calls
|
|
446
|
+
const refreshResponse = await internalRefresh(
|
|
447
|
+
req.headers.get('cookie') || '',
|
|
448
|
+
sessionToken,
|
|
449
|
+
sessionData.idpRefreshToken,
|
|
450
|
+
ctx.requestId
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
if (!refreshResponse.ok) {
|
|
454
|
+
return {};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const refreshed = await getSession(sessionToken);
|
|
458
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
459
|
+
const refreshedAccessToken = (refreshed as any)?.accessToken || (refreshed as any)?.idpAccessToken;
|
|
460
|
+
if (refreshedAccessToken) {
|
|
461
|
+
return {
|
|
462
|
+
accessToken: refreshedAccessToken,
|
|
463
|
+
roles: Array.isArray(refreshed?.roles) ? refreshed.roles : [],
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {};
|
|
468
|
+
|
|
469
|
+
} finally {
|
|
470
|
+
await releaseRefreshLock(sessionToken, ctx.requestId, lockResult.lockInfo.lockVersion);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private async waitForRefresh(sessionToken: string, maxWaitMs: number): Promise<{ success: boolean }> {
|
|
475
|
+
const startTime = Date.now();
|
|
476
|
+
const pollInterval = 100;
|
|
477
|
+
|
|
478
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
479
|
+
const lockExists = await checkRefreshLock(sessionToken);
|
|
480
|
+
if (!lockExists) {
|
|
481
|
+
const session = await getSession(sessionToken);
|
|
482
|
+
if (session?.accessToken) {
|
|
483
|
+
const expires = session.accessTokenExpires || 0;
|
|
484
|
+
if ((expires - Date.now()) > (5 * 60 * 1000)) {
|
|
485
|
+
return { success: true };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { success: false };
|
|
489
|
+
}
|
|
490
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return { success: false };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ==========================================================================
|
|
497
|
+
// Response Helpers
|
|
498
|
+
// ==========================================================================
|
|
499
|
+
|
|
500
|
+
private createEmptyAuth(): ApiAuthContext {
|
|
501
|
+
return {
|
|
502
|
+
accessToken: null,
|
|
503
|
+
roles: [],
|
|
504
|
+
tokenType: 'bearer',
|
|
505
|
+
refreshToken: null,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private async executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
510
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
511
|
+
setTimeout(() => reject(new Error('Request timeout')), timeoutMs);
|
|
512
|
+
});
|
|
513
|
+
return Promise.race([promise, timeoutPromise]);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private successResponse(data: any, requestId: string, ctx: ApiRequestContext): NextResponse {
|
|
517
|
+
// Pass through Response objects unchanged
|
|
518
|
+
if (data instanceof Response) {
|
|
519
|
+
return data as any;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Handle nested success/error flags
|
|
523
|
+
if (data && typeof data === 'object') {
|
|
524
|
+
if (Object.prototype.hasOwnProperty.call(data, 'success')) {
|
|
525
|
+
if (data.success === false) {
|
|
526
|
+
const msg = data.error?.message || data.message || 'Upstream service error';
|
|
527
|
+
const code = data.error?.code || 'UPSTREAM_SERVICE_ERROR';
|
|
528
|
+
return this.errorResponse(code, msg, 400, requestId);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return NextResponse.json({
|
|
534
|
+
success: true,
|
|
535
|
+
data,
|
|
536
|
+
message: 'Operation completed successfully',
|
|
537
|
+
operation_code: 'api_handler_operation',
|
|
538
|
+
timestamp: new Date().toISOString(),
|
|
539
|
+
request_id: requestId,
|
|
540
|
+
meta: {
|
|
541
|
+
version: '1.0',
|
|
542
|
+
responseTime: Date.now() - ctx.startTime,
|
|
543
|
+
}
|
|
544
|
+
}, { status: 200 });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private errorResponse(code: string, message: string, status: number, requestId: string): NextResponse {
|
|
548
|
+
const now = new Date().toISOString();
|
|
549
|
+
return NextResponse.json({
|
|
550
|
+
success: false,
|
|
551
|
+
message,
|
|
552
|
+
operation_code: 'api_handler_error',
|
|
553
|
+
timestamp: now,
|
|
554
|
+
request_id: requestId,
|
|
555
|
+
error: {
|
|
556
|
+
code,
|
|
557
|
+
message,
|
|
558
|
+
support: { request_id: requestId, timestamp: now }
|
|
559
|
+
},
|
|
560
|
+
meta: { version: '1.0' }
|
|
561
|
+
}, { status });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private getStatusFromError(error: Error): number {
|
|
565
|
+
const msg = error.message.toLowerCase();
|
|
566
|
+
|
|
567
|
+
if (msg.includes('unauthorized')) return 401;
|
|
568
|
+
if (msg.includes('forbidden') || msg.includes('insufficient permissions')) return 403;
|
|
569
|
+
if (msg.includes('not found')) return 404;
|
|
570
|
+
if (msg.includes('timeout')) return 408;
|
|
571
|
+
if (msg.includes('rate limit')) return 429;
|
|
572
|
+
if (msg.includes('bad gateway') || msg.includes('upstream')) return 502;
|
|
573
|
+
if (msg.includes('service unavailable')) return 503;
|
|
574
|
+
|
|
575
|
+
return 500;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private getErrorCode(status: number): string {
|
|
579
|
+
switch (status) {
|
|
580
|
+
case 400: return 'BAD_REQUEST';
|
|
581
|
+
case 401: return 'UNAUTHORIZED';
|
|
582
|
+
case 403: return 'FORBIDDEN';
|
|
583
|
+
case 404: return 'NOT_FOUND';
|
|
584
|
+
case 408: return 'TIMEOUT';
|
|
585
|
+
case 429: return 'RATE_LIMIT_EXCEEDED';
|
|
586
|
+
case 502: return 'BAD_GATEWAY';
|
|
587
|
+
case 503: return 'SERVICE_UNAVAILABLE';
|
|
588
|
+
default: return 'INTERNAL_SERVER_ERROR';
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ============================================================================
|
|
594
|
+
// Factory Function
|
|
595
|
+
// ============================================================================
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Create an API handler with the specified configuration
|
|
599
|
+
*
|
|
600
|
+
* @example
|
|
601
|
+
* ```typescript
|
|
602
|
+
* export const GET = createApiHandler({ requireAuth: true })
|
|
603
|
+
* .handle(async (req, ctx, auth) => {
|
|
604
|
+
* return { data: 'hello' };
|
|
605
|
+
* });
|
|
606
|
+
* ```
|
|
607
|
+
*/
|
|
608
|
+
export function createApiHandler(config: ApiHandlerConfig = {}): ApiHandler {
|
|
609
|
+
return new ApiHandler(config);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// Pre-configured Presets
|
|
614
|
+
// ============================================================================
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Pre-configured API handler presets for common use cases
|
|
618
|
+
*/
|
|
619
|
+
export const API_PRESETS = {
|
|
620
|
+
/** Public endpoint - no authentication required */
|
|
621
|
+
PUBLIC: () => createApiHandler({ requireAuth: false, validateClientSlug: false }),
|
|
622
|
+
|
|
623
|
+
/** Authenticated endpoint - any logged-in user */
|
|
624
|
+
AUTHENTICATED: () => createApiHandler({ requireAuth: true }),
|
|
625
|
+
|
|
626
|
+
/** User endpoint - requires payez_user or payez_admin role */
|
|
627
|
+
USER: () => createApiHandler({
|
|
628
|
+
requireAuth: true,
|
|
629
|
+
requiredRoles: ['payez_user', 'payez_admin'],
|
|
630
|
+
}),
|
|
631
|
+
|
|
632
|
+
/** Admin endpoint - requires payez_admin role */
|
|
633
|
+
ADMIN: () => createApiHandler({
|
|
634
|
+
requireAuth: true,
|
|
635
|
+
requiredRoles: ['payez_admin'],
|
|
636
|
+
}),
|
|
637
|
+
} as const;
|
|
638
|
+
|
|
639
|
+
// ============================================================================
|
|
640
|
+
// Default Export
|
|
641
|
+
// ============================================================================
|
|
642
|
+
|
|
643
|
+
export default createApiHandler;
|