@rxbenefits/server-utils 0.1.0

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/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@rxbenefits/server-utils",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "dependencies": {
8
+ "@opentelemetry/api": "^1.9.0",
9
+ "axios": "^1.7.9",
10
+ "formidable": "^3.5.2",
11
+ "next": "^16.1.1",
12
+ "node-fetch": "^3.3.2"
13
+ },
14
+ "devDependencies": {
15
+ "@types/formidable": "^3.4.5",
16
+ "@types/node": "^20"
17
+ },
18
+ "peerDependencies": {},
19
+ "scripts": {
20
+ "build": "echo 'Server utils uses source files directly'",
21
+ "lint": "eslint . --ext .ts,.tsx"
22
+ }
23
+ }
@@ -0,0 +1,132 @@
1
+ import { context, trace } from '@opentelemetry/api';
2
+ import axios, {
3
+ type AxiosHeaderValue,
4
+ type AxiosInstance,
5
+ type AxiosRequestConfig,
6
+ type Method,
7
+ } from 'axios';
8
+
9
+ /**
10
+ * Configuration for creating a backend API client
11
+ */
12
+ export interface ApiClientConfig {
13
+ /** Function to get WAF bypass token */
14
+ getWafBypassToken?: () => string | undefined;
15
+ /** Optional base URL for API requests */
16
+ baseURL?: string;
17
+ /** Enable tracing headers */
18
+ enableTracing?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Creates a configured axios instance with automatic header injection
23
+ * Automatically adds:
24
+ * - Authorization header (Bearer token)
25
+ * - WAF bypass header (X-RXB-Bypass)
26
+ * - Tracing headers (traceparent)
27
+ * - Application header
28
+ */
29
+ export function createBackendApiClient(config: ApiClientConfig = {}): {
30
+ request: <T = unknown>(options: AxiosRequestConfig & { accessToken: string }) => Promise<T>;
31
+ getInstance: () => AxiosInstance;
32
+ } {
33
+ const { getWafBypassToken, baseURL, enableTracing = true } = config;
34
+
35
+ const instance = axios.create({
36
+ baseURL,
37
+ timeout: 30000,
38
+ });
39
+
40
+ // Add request interceptor to inject headers
41
+ instance.interceptors.request.use((requestConfig) => {
42
+ const headers = requestConfig.headers || {};
43
+
44
+ // Get tracing context if enabled
45
+ if (enableTracing) {
46
+ const span = trace.getSpan(context.active());
47
+ const traceContext = span?.spanContext();
48
+ if (traceContext) {
49
+ headers['traceparent'] = `00-${traceContext.traceId}-${traceContext.spanId}-01`;
50
+ }
51
+ }
52
+
53
+ // Add Application header
54
+ headers['Application'] = 'authorization';
55
+
56
+ requestConfig.headers = headers;
57
+ return requestConfig;
58
+ });
59
+
60
+ return {
61
+ /**
62
+ * Make an API request with automatic header injection
63
+ * @param options Axios request config with accessToken
64
+ * @returns Response data
65
+ */
66
+ request: async <T = unknown>(
67
+ options: AxiosRequestConfig & { accessToken: string }
68
+ ): Promise<T> => {
69
+ const { accessToken, ...axiosConfig } = options;
70
+
71
+ const headers: Record<string, AxiosHeaderValue> = {
72
+ ...(axiosConfig.headers as Record<string, AxiosHeaderValue>),
73
+ Authorization: `Bearer ${accessToken}`,
74
+ };
75
+
76
+ // Add WAF bypass header if available
77
+ const wafToken = getWafBypassToken?.();
78
+ if (wafToken) {
79
+ headers['X-RXB-Bypass'] = wafToken;
80
+ }
81
+
82
+ const response = await instance.request<T>({
83
+ ...axiosConfig,
84
+ headers,
85
+ });
86
+
87
+ return response.data;
88
+ },
89
+
90
+ getInstance: () => instance,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Create headers for backend API requests with all required tokens
96
+ * Useful for fetch() or other HTTP clients
97
+ */
98
+ export function createBackendHeaders(options: {
99
+ accessToken: string;
100
+ wafBypassToken?: string;
101
+ contentType?: string;
102
+ enableTracing?: boolean;
103
+ }): Record<string, string> {
104
+ const {
105
+ accessToken,
106
+ wafBypassToken,
107
+ contentType = 'application/json',
108
+ enableTracing = true,
109
+ } = options;
110
+
111
+ const headers: Record<string, string> = {
112
+ 'Content-Type': contentType,
113
+ Authorization: `Bearer ${accessToken}`,
114
+ Application: 'authorization',
115
+ };
116
+
117
+ // Add WAF bypass header if provided
118
+ if (wafBypassToken) {
119
+ headers['X-RXB-Bypass'] = wafBypassToken;
120
+ }
121
+
122
+ // Add tracing if enabled
123
+ if (enableTracing) {
124
+ const span = trace.getSpan(context.active());
125
+ const traceContext = span?.spanContext();
126
+ if (traceContext) {
127
+ headers['traceparent'] = `00-${traceContext.traceId}-${traceContext.spanId}-01`;
128
+ }
129
+ }
130
+
131
+ return headers;
132
+ }
@@ -0,0 +1,369 @@
1
+ import * as fs from 'fs';
2
+
3
+ import { getLogger, getUser } from '@rxbenefits/utils/logger';
4
+ import { ProviderSetup } from '@rxbenefits/utils/tracer';
5
+ import { context, trace } from '@opentelemetry/api';
6
+ import axios, { type Method } from 'axios';
7
+ import * as formidable from 'formidable';
8
+ import type { NextApiRequest, NextApiResponse } from 'next';
9
+ import fetch, { Blob, FormData } from 'node-fetch';
10
+
11
+ import { createBackendHeaders } from './api-client';
12
+
13
+ const logger = getLogger();
14
+ const shouldLogAuthDebug = Boolean(process.env.AWS_REGION || process.env.AUTH0_DEBUG);
15
+
16
+ const getCookieNames = (cookieHeader?: string | string[]) => {
17
+ const raw = Array.isArray(cookieHeader) ? cookieHeader.join(';') : cookieHeader;
18
+ if (!raw) return [];
19
+ return raw
20
+ .split(';')
21
+ .map((cookie) => cookie.trim().split('=')[0])
22
+ .filter(Boolean);
23
+ };
24
+
25
+ const buildAuthDebugInfo = (req: NextApiRequest) => {
26
+ const cookieNames = getCookieNames(req.headers.cookie);
27
+ const hasDefaultSession = cookieNames.some(
28
+ (name) => name === 'appSession' || name.startsWith('appSession.')
29
+ );
30
+
31
+ return {
32
+ method: req.method,
33
+ url: req.url,
34
+ host: req.headers.host,
35
+ forwardedHost: req.headers['x-forwarded-host'],
36
+ forwardedProto: req.headers['x-forwarded-proto'],
37
+ referer: req.headers.referer,
38
+ cookieCount: cookieNames.length,
39
+ cookieNames,
40
+ hasAdminPortalSession: cookieNames.includes('admin_portal_session'),
41
+ hasAdminPortalTransaction: cookieNames.includes('admin_portal_auth_verification'),
42
+ hasDefaultSession,
43
+ };
44
+ };
45
+
46
+ const buildUpstreamDebugInfo = (params: {
47
+ route: string;
48
+ targetUrl: string;
49
+ method: Method;
50
+ sendType: string;
51
+ error: unknown;
52
+ status?: number;
53
+ }) => {
54
+ const error = params.error as { name?: string; code?: string; message?: string } | undefined;
55
+ return {
56
+ route: params.route,
57
+ targetUrl: params.targetUrl,
58
+ method: params.method,
59
+ sendType: params.sendType,
60
+ status: params.status,
61
+ errorName: error?.name,
62
+ errorCode: error?.code,
63
+ errorMessage: error?.message,
64
+ };
65
+ };
66
+
67
+ /**
68
+ * Service configuration for callJava handler
69
+ * Can be a static object or a function that returns config (for lazy evaluation)
70
+ *
71
+ * AI-Modified: Added function support for lazy evaluation of env vars
72
+ * In Lambda/SSR, module-level code runs before loadAuth0Config() populates process.env
73
+ * Modified-Date: 2026-01-21
74
+ */
75
+ export type ServiceConfig = Record<string, string> | (() => Record<string, string>);
76
+
77
+ /**
78
+ * Request body shape for callJava endpoint
79
+ */
80
+ export type CallJavaRequestBody = {
81
+ sendType?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'blob';
82
+ method: Method;
83
+ data?: Record<string, any>;
84
+ route: string;
85
+ };
86
+
87
+ /**
88
+ * Configuration for auth functions
89
+ */
90
+ export interface CallJavaAuthConfig {
91
+ getAccessToken: (req: NextApiRequest, res: NextApiResponse) => Promise<{ accessToken?: string }>;
92
+ withApiAuthRequired: (handler: any) => any;
93
+ getWafBypassToken: () => string | undefined;
94
+ }
95
+
96
+ /**
97
+ * Creates a Next.js API route handler for the callJava pattern
98
+ * Handles proxying requests to backend services with automatic header injection
99
+ *
100
+ * @param services Service key -> base URL mapping
101
+ * @param auth Auth0 functions for authentication and WAF bypass
102
+ * @returns Next.js API route handler
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * import { createCallJavaHandler } from '@rxbenefits/server-utils';
107
+ * import { getAccessToken, withApiAuthRequired, getWafBypassToken } from '../../lib/auth0';
108
+ *
109
+ * const services = {
110
+ * 'fms-commission-service': process.env.FMSCOMMISSIONAPIURI || '',
111
+ * 'ben-admin': process.env.BENADMINAPIURI || '',
112
+ * };
113
+ *
114
+ * export const config = { api: { bodyParser: false } };
115
+ * export default createCallJavaHandler(services, {
116
+ * getAccessToken,
117
+ * withApiAuthRequired,
118
+ * getWafBypassToken,
119
+ * });
120
+ * ```
121
+ */
122
+ export function createCallJavaHandler(services: ServiceConfig, auth: CallJavaAuthConfig) {
123
+ return auth.withApiAuthRequired(async function callJava(
124
+ req: NextApiRequest,
125
+ res: NextApiResponse
126
+ ) {
127
+ ProviderSetup();
128
+ const span = trace.getSpan(context.active());
129
+
130
+ try {
131
+ const { accessToken } = await auth.getAccessToken(req, res);
132
+
133
+ if (!accessToken) {
134
+ if (shouldLogAuthDebug) {
135
+ logger.error('[AuthDebug] /api/callJava missing access token', buildAuthDebugInfo(req));
136
+ }
137
+ logger.error('no access token');
138
+ res.status(401).end(JSON.stringify({ message: 'no access token' }));
139
+ return;
140
+ }
141
+
142
+ const username = getUser(accessToken);
143
+ span?.setAttribute('app.user', username);
144
+
145
+ const reqData: any = await getFormData(req);
146
+ const formattedData = formatData(reqData);
147
+ span?.setAttribute('http.target_route', formattedData.fields.route);
148
+ span?.setAttribute('http.target_method', formattedData.fields.method);
149
+
150
+ // legacy shape: move `data` to `body`
151
+ formattedData.fields.body = formattedData.fields.data;
152
+ delete formattedData.data;
153
+
154
+ // AI-Modified: Resolve services if it's a function (lazy evaluation for Lambda)
155
+ const resolvedServices = typeof services === 'function' ? services() : services;
156
+
157
+ // Debug logging for Lambda environment
158
+ if (shouldLogAuthDebug) {
159
+ const route = formattedData.fields?.route || '';
160
+ const serviceKey = Object.keys(resolvedServices).find(key => route.includes(key));
161
+ const serviceUrl = serviceKey ? resolvedServices[serviceKey] : 'unknown';
162
+ logger.info('[CallJava] Service resolution', {
163
+ route,
164
+ serviceKey: serviceKey || 'none',
165
+ serviceUrl: serviceUrl ? `${serviceUrl.substring(0, 50)}...` : 'EMPTY',
166
+ hasEnvVars: {
167
+ TASKSERVICEAPIURI: !!process.env.TASKSERVICEAPIURI,
168
+ ELIGIBILITYIMPORTAPIURI: !!process.env.ELIGIBILITYIMPORTAPIURI,
169
+ }
170
+ });
171
+ }
172
+
173
+ const response: any = await axiosRequest(
174
+ accessToken,
175
+ auth.getWafBypassToken(),
176
+ formattedData,
177
+ resolvedServices
178
+ );
179
+
180
+ if (response?.headers?.['content-disposition']) {
181
+ res.setHeader('content-disposition', response.headers['content-disposition']);
182
+ }
183
+
184
+ // If axios response, send only the upstream payload
185
+ if (response && typeof response === 'object' && 'data' in response) {
186
+ res.send((response as { data: unknown }).data);
187
+ } else if (
188
+ response &&
189
+ typeof response === 'object' &&
190
+ typeof (response as any).pipe === 'function'
191
+ ) {
192
+ // node-fetch stream body (multipart)
193
+ (response as any).pipe(res);
194
+ } else {
195
+ res.status(500).send({ message: 'Upstream request failed' });
196
+ }
197
+ span?.end();
198
+ } catch (error: any) {
199
+ if (shouldLogAuthDebug) {
200
+ logger.error('[AuthDebug] /api/callJava error', {
201
+ error: error instanceof Error ? error.message : String(error),
202
+ ...buildAuthDebugInfo(req),
203
+ });
204
+ }
205
+ logger.error(error);
206
+ const status: number = error?.response?.status || 500;
207
+ // Avoid leaking upstream error details to the client
208
+ res.status(status).send({ message: 'Request failed' });
209
+ span?.end();
210
+ }
211
+ });
212
+ }
213
+
214
+ const formatData = (data: any) => {
215
+ if (typeof data.fields.method === 'string') return data;
216
+ const newData = data;
217
+ const formattedData: Record<string, string> = {};
218
+ const keys = Object.keys(newData.fields);
219
+ for (let i = 0; i < keys.length; i++) {
220
+ const val = newData.fields[keys[i]][0];
221
+ formattedData[keys[i]] = val;
222
+ }
223
+ newData.fields = formattedData;
224
+ return newData;
225
+ };
226
+
227
+ const getFormData = async (req: any) => {
228
+ const data = await new Promise((resolve, reject) => {
229
+ const form = new formidable.Formidable();
230
+
231
+ form.parse(req, (err, fields, files) => {
232
+ if (err) reject({ err });
233
+ resolve({ err, fields, files });
234
+ });
235
+ });
236
+ return data;
237
+ };
238
+
239
+ const findService = (url: string, services: Record<string, string>) => {
240
+ let newUrl = url;
241
+ const keys = Object.keys(services);
242
+
243
+ for (let i = 0; i < keys.length; i++) {
244
+ const key = keys[i];
245
+ const replacement = services[key];
246
+ if (replacement && newUrl.includes(key)) {
247
+ newUrl = newUrl.replace(key, replacement);
248
+ break;
249
+ }
250
+ }
251
+ return newUrl;
252
+ };
253
+
254
+ const axiosRequest = async (
255
+ accessToken: string,
256
+ wafBypassToken: string | undefined,
257
+ requestData: any,
258
+ services: Record<string, string>
259
+ ) => {
260
+ const {
261
+ route: url,
262
+ method = 'GET',
263
+ body,
264
+ sendType = 'application/json',
265
+ ...rest
266
+ } = requestData.fields;
267
+
268
+ let sendBody = body;
269
+
270
+ // Create headers with WAF bypass token
271
+ const headers = createBackendHeaders({
272
+ accessToken,
273
+ wafBypassToken,
274
+ contentType: sendType,
275
+ enableTracing: true,
276
+ });
277
+
278
+ if (sendType === 'blob') {
279
+ (headers as any).responseType = 'arraybuffer';
280
+ }
281
+
282
+ if (sendType === 'multipart/form-data') {
283
+ sendBody = { ...rest };
284
+ const form = new FormData();
285
+ const keys = Object.keys(sendBody);
286
+ const fileKeys = Object.keys(requestData.files || {});
287
+
288
+ for (let i = 0; i < keys.length; i++) {
289
+ form.append(keys[i], sendBody[keys[i]]);
290
+ }
291
+
292
+ if (fileKeys.length) {
293
+ for (let i = 0; i < fileKeys.length; i++) {
294
+ const file = requestData.files[fileKeys[i]]?.[0];
295
+ if (!file?.filepath) continue;
296
+ const buffer = fs.readFileSync(file.filepath);
297
+ const blob = new Blob([new Uint8Array(buffer)]);
298
+ form.append(fileKeys[i], blob, file?.originalFilename);
299
+ }
300
+ }
301
+
302
+ const targetUrl: string = findService(url, services) || '';
303
+ try {
304
+ const response = await fetch(targetUrl, {
305
+ method,
306
+ body: form,
307
+ headers,
308
+ });
309
+
310
+ if (!response.ok && shouldLogAuthDebug) {
311
+ logger.error(
312
+ buildUpstreamDebugInfo({
313
+ route: url,
314
+ targetUrl,
315
+ method,
316
+ sendType,
317
+ status: response.status,
318
+ error: new Error(response.statusText),
319
+ }),
320
+ '[ApiDebug] /api/callJava upstream fetch response'
321
+ );
322
+ }
323
+
324
+ // For uploads/downloads, return the raw stream body
325
+ return response.body;
326
+ } catch (error) {
327
+ if (shouldLogAuthDebug) {
328
+ logger.error(
329
+ buildUpstreamDebugInfo({
330
+ route: url,
331
+ targetUrl,
332
+ method,
333
+ sendType,
334
+ error,
335
+ }),
336
+ '[ApiDebug] /api/callJava upstream fetch failed'
337
+ );
338
+ }
339
+ throw error;
340
+ }
341
+ }
342
+
343
+ const targetUrl: string = findService(url, services) || '';
344
+
345
+ try {
346
+ return await axios.request({
347
+ url: targetUrl,
348
+ method,
349
+ data: sendBody,
350
+ headers,
351
+ responseType: (headers as any).responseType,
352
+ });
353
+ } catch (error) {
354
+ if (shouldLogAuthDebug) {
355
+ logger.error(
356
+ buildUpstreamDebugInfo({
357
+ route: url,
358
+ targetUrl,
359
+ method,
360
+ sendType,
361
+ status: (error as any)?.response?.status,
362
+ error,
363
+ }),
364
+ '[ApiDebug] /api/callJava upstream request failed'
365
+ );
366
+ }
367
+ throw error;
368
+ }
369
+ };
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @rxbenefits/server-utils
3
+ *
4
+ * Shared server-side utilities for Next.js API routes
5
+ * Provides centralized handling of:
6
+ * - WAF bypass headers
7
+ * - Authentication headers
8
+ * - Tracing headers
9
+ * - Backend API proxying
10
+ */
11
+
12
+ export * from './api-client';
13
+ export * from './call-java-handler';
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": ".."
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }