bulltrackers-module 1.0.622 → 1.0.624

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.
@@ -1,6 +1,7 @@
1
1
  // Firestore helper functions for fetching data from collections
2
- const { FieldValue } = require('@google-cloud/firestore');
2
+ const { FieldValue, Timestamp } = require('@google-cloud/firestore');
3
3
  const { dispatchSyncRequest } = require('../task_engine_helper.js');
4
+ const { sanitizeCid, sanitizeDocId } = require('../security_utils.js');
4
5
  const crypto = require('crypto');
5
6
 
6
7
  // 1. Fetch latest stored snapshots of user data from a user-centric collection
@@ -34,11 +35,14 @@ const crypto = require('crypto');
34
35
  */
35
36
  const latestUserCentricSnapshot = async (firestore, userId, collectionName, dataType, userType, documentName, fields = []) => {
36
37
  try {
37
- const baseRef = firestore.collection(userType).doc(userId).collection(collectionName);
38
+ // Sanitize user inputs to prevent injection attacks
39
+ const sanitizedUserId = sanitizeCid(userId);
40
+ const sanitizedDocName = documentName ? sanitizeDocId(documentName) : null;
41
+ const baseRef = firestore.collection(userType).doc(sanitizedUserId).collection(collectionName);
38
42
 
39
- if (documentName) {
43
+ if (sanitizedDocName) {
40
44
  // SCENARIO 1: Single Document
41
- let docRef = baseRef.doc(documentName);
45
+ let docRef = baseRef.doc(sanitizedDocName);
42
46
  // Apply field selection if fields are specified (Firestore projection)
43
47
  if (fields.length > 0) {
44
48
  docRef = docRef.select(...fields);
@@ -76,6 +80,10 @@ const latestUserCentricSnapshot = async (firestore, userId, collectionName, data
76
80
 
77
81
  const pageCollection = async (firestore, dateStr, computationName, userId, lookbackDays = 7) => {
78
82
  try {
83
+ // Sanitize user inputs
84
+ const sanitizedUserId = sanitizeCid(userId);
85
+ const sanitizedComputationName = sanitizeDocId(computationName);
86
+
79
87
  const endDate = new Date(dateStr);
80
88
  const startDate = new Date(endDate);
81
89
  startDate.setDate(endDate.getDate() - lookbackDays);
@@ -84,15 +92,15 @@ const pageCollection = async (firestore, dateStr, computationName, userId, lookb
84
92
  const dateKey = d.toISOString().split('T')[0];
85
93
  const docRef = firestore.collection('unified_insights').doc(dateKey)
86
94
  .collection('results').doc('popular-investor')
87
- .collection('computations').doc(computationName)
88
- .collection('pages').doc(userId);
95
+ .collection('computations').doc(sanitizedComputationName)
96
+ .collection('pages').doc(sanitizedUserId);
89
97
  const docSnapshot = await docRef.get();
90
98
  if (docSnapshot.exists) {
91
99
  results.push({ date: dateKey, data: docSnapshot.data() });
92
100
  }
93
101
  }
94
102
  if (results.length === 0) {
95
- throw new Error(`No page data found for User ID ${userId} in computation ${computationName} within the last ${lookbackDays} days`);
103
+ throw new Error(`No page data found for User ID ${sanitizedUserId} in computation ${sanitizedComputationName} within the last ${lookbackDays} days`);
96
104
  }
97
105
  // Sort results by date descending (newest first) so the latest data is always first
98
106
  results.sort((a, b) => {
@@ -370,9 +378,12 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
370
378
  const todayStr = new Date().toISOString().split('T')[0]; // "2026-01-09"
371
379
  const batch = db.batch();
372
380
 
381
+ // Sanitize user inputs
382
+ const sanitizedUserId = sanitizeCid(userId);
383
+
373
384
  // 1. Determine Watchlist ID and Reference
374
- const watchlistId = payload.id || `watchlist_${Date.now()}_${Math.random().toString(16).substr(2, 8)}`;
375
- const userDocRef = db.collection('SignedInUsers').doc(userId).collection('watchlists').doc(watchlistId);
385
+ const watchlistId = payload.id ? sanitizeDocId(payload.id) : `watchlist_${Date.now()}_${Math.random().toString(16).substr(2, 8)}`;
386
+ const userDocRef = db.collection('SignedInUsers').doc(sanitizedUserId).collection('watchlists').doc(watchlistId);
376
387
 
377
388
  try {
378
389
  let addedItems = []; // Items newly added (needs Master Log + Current Counter Increment)
@@ -397,7 +408,7 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
397
408
  const newDocData = {
398
409
  ...payload,
399
410
  id: watchlistId,
400
- createdBy: userId,
411
+ createdBy: sanitizedUserId,
401
412
  createdAt: FieldValue.serverTimestamp(),
402
413
  updatedAt: FieldValue.serverTimestamp(),
403
414
  copyCount: 0,
@@ -447,7 +458,7 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
447
458
  totalUsers: FieldValue.increment(1),
448
459
  privateWatchlistCount: FieldValue.increment(isPrivate ? 1 : 0),
449
460
  publicWatchlistCount: FieldValue.increment(isPrivate ? 0 : 1),
450
- users: FieldValue.arrayUnion(userId)
461
+ users: FieldValue.arrayUnion(sanitizedUserId)
451
462
  }, { merge: true });
452
463
 
453
464
  // B. Update PI Real-time Counter (Increment)
@@ -459,12 +470,12 @@ const manageUserWatchlist = async (db, userId, instruction, payload = {}) => {
459
470
  batch.set(piCounterRef, {
460
471
  lastUpdated: FieldValue.serverTimestamp(),
461
472
  totalUsers: FieldValue.increment(1),
462
- userCids: FieldValue.arrayUnion(userId),
473
+ userCids: FieldValue.arrayUnion(sanitizedUserId),
463
474
  // Nested Map Update for Daily stats
464
475
  [dailyField]: {
465
476
  count: FieldValue.increment(1),
466
477
  timestamp: FieldValue.serverTimestamp(),
467
- userCids: FieldValue.arrayUnion(userId)
478
+ userCids: FieldValue.arrayUnion(sanitizedUserId)
468
479
  }
469
480
  }, { merge: true });
470
481
  });
@@ -618,10 +629,20 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
618
629
  const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
619
630
 
620
631
  if (normalizedEmails.includes(normalizedEmail)) {
621
- // Optional: Validate Firebase UID if provided
622
- if (firebaseUid && verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
623
- if (!verificationData.firebaseUids.includes(firebaseUid)) {
624
- console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${cid} but Firebase UID ${firebaseUid} not in firebaseUids array`);
632
+ // Validate and enforce Firebase UID if provided
633
+ if (firebaseUid) {
634
+ if (verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
635
+ // New users: strict enforcement
636
+ if (!verificationData.firebaseUids.includes(firebaseUid)) {
637
+ throw new Error(`Firebase UID not authorized for this CID`);
638
+ }
639
+ } else {
640
+ // Legacy users: populate firebaseUids array (migration)
641
+ // Note: A single CID can have multiple emails/UIDs for different accounts
642
+ await verificationRef.update({
643
+ firebaseUids: FieldValue.arrayUnion(firebaseUid)
644
+ });
645
+ console.log(`[Migration] Added Firebase UID ${firebaseUid} to CID ${cid}`);
625
646
  }
626
647
  }
627
648
 
@@ -666,10 +687,20 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
666
687
  if (normalizedEmails.includes(normalizedEmail)) {
667
688
  const cid = verificationData.etoroCID || Number(userDoc.id);
668
689
 
669
- // Optional: Validate Firebase UID if provided
670
- if (firebaseUid && verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
671
- if (!verificationData.firebaseUids.includes(firebaseUid)) {
672
- console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${cid} but Firebase UID ${firebaseUid} not in firebaseUids array`);
690
+ // Validate and enforce Firebase UID if provided
691
+ if (firebaseUid) {
692
+ if (verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
693
+ // New users: strict enforcement
694
+ if (!verificationData.firebaseUids.includes(firebaseUid)) {
695
+ throw new Error(`Firebase UID not authorized for this CID`);
696
+ }
697
+ } else {
698
+ // Legacy users: populate firebaseUids array (migration)
699
+ // Note: A single CID can have multiple emails/UIDs for different accounts
700
+ await verificationRef.update({
701
+ firebaseUids: FieldValue.arrayUnion(firebaseUid)
702
+ });
703
+ console.log(`[Migration] Added Firebase UID ${firebaseUid} to CID ${cid}`);
673
704
  }
674
705
  }
675
706
 
@@ -734,8 +765,7 @@ const requestPopularInvestorAddition = async (firestore, userId, piId, piUsernam
734
765
  piUsername,
735
766
  requestedBy: userId,
736
767
  requestedAt: FieldValue.serverTimestamp(),
737
- ttl: admin.firestore.Timestamp.fromDate(ttlDate)
738
-
768
+ ttl: Timestamp.fromDate(ttlDate)
739
769
  });
740
770
  const globalRequestRef = firestore.collection('PiAdditionRequestLogs').doc(todayStr).collection('requests').doc(piId);
741
771
  await globalRequestRef.set({
@@ -933,14 +963,18 @@ const hasUserCopiedPopularInvestor = async (firestore, userId, popularInvestorId
933
963
  // 3. A GLOBAL REVIEWS COLLECTION LIKE /PiReviews/YYYY-MM-DD/document_name containing a map of the reviews made each day, who made them, for which PI and text content
934
964
  // 4. We need a migration strategy to pull across the existing stored data from the old collection path into the new collection path upon read.
935
965
  const manageReviews = async (db, userId, action, params = {}) => {
966
+ // Sanitize user inputs
967
+ const sanitizedUserId = sanitizeCid(userId);
936
968
  const { piId } = params;
969
+ const sanitizedPiId = sanitizeCid(piId);
970
+
937
971
  const batch = db.batch();
938
972
  const todayStr = new Date().toISOString().split('T')[0];
939
973
 
940
- // Paths
941
- const newReviewPath = db.collection('SignedInUsers').doc(userId).collection('reviews').doc(piId.toString());
942
- const piReviewPath = db.collection('PopularInvestors').doc(piId.toString()).collection('reviews').doc(`${userId}_${piId}`);
943
- const legacyReviewPath = db.collection('pi_reviews').doc(`${userId}_${piId}`);
974
+ // Paths - all user inputs sanitized
975
+ const newReviewPath = db.collection('SignedInUsers').doc(sanitizedUserId).collection('reviews').doc(sanitizedPiId);
976
+ const piReviewPath = db.collection('PopularInvestors').doc(sanitizedPiId).collection('reviews').doc(`${sanitizedUserId}_${sanitizedPiId}`);
977
+ const legacyReviewPath = db.collection('pi_reviews').doc(`${sanitizedUserId}_${sanitizedPiId}`);
944
978
  const globalLogPath = db.collection('PiReviews').doc(todayStr).collection('shards').doc('daily_log');
945
979
 
946
980
  try {
@@ -2048,9 +2082,87 @@ const searchPopularInvestors = async (db, queryStr) => {
2048
2082
  // NEW: SYNC & RATE LIMITS (Rec 7, 9, 15)
2049
2083
  // ==========================================
2050
2084
 
2051
- const checkSyncRateLimits = async (db, targetId, requesterId, isDev) => {
2052
- if (isDev) return { allowed: true };
2085
+ const checkSyncRateLimits = async (db, targetId, requesterId, isDev, maxRequests = 1, windowMs = 6 * 60 * 60 * 1000) => {
2086
+ // Developers get higher limits (100 requests per hour globally, 10 per target) but still limited
2087
+ if (isDev) {
2088
+ const DEV_GLOBAL_LIMIT_MS = 60 * 60 * 1000; // 1 hour
2089
+ const DEV_GLOBAL_MAX_REQUESTS = 100; // Total requests per hour
2090
+ const DEV_PER_TARGET_LIMIT_MS = 60 * 60 * 1000; // 1 hour
2091
+ const DEV_PER_TARGET_MAX_REQUESTS = 10; // Requests per target per hour
2092
+ const now = Date.now();
2093
+
2094
+ // Check global developer limit (total requests across all targets)
2095
+ const globalDevRef = db.collection('developer_rate_limits').doc(String(requesterId)).collection('global').doc('latest');
2096
+ const globalDevSnap = await globalDevRef.get();
2097
+
2098
+ let globalRequestCount = 0;
2099
+ let globalWindowStart = now;
2100
+
2101
+ if (globalDevSnap.exists) {
2102
+ const globalData = globalDevSnap.data();
2103
+ globalRequestCount = globalData.requestCount || 0;
2104
+ globalWindowStart = globalData.windowStart?.toMillis() || now;
2105
+
2106
+ // Reset window if expired
2107
+ if (now - globalWindowStart >= DEV_GLOBAL_LIMIT_MS) {
2108
+ globalRequestCount = 0;
2109
+ globalWindowStart = now;
2110
+ }
2111
+ }
2112
+
2113
+ // Check per-target limit (prevent spamming specific users)
2114
+ const sanitizedTargetId = sanitizeCid(targetId);
2115
+ const perTargetRef = db.collection('user_sync_requests').doc(sanitizedTargetId).collection('developer_limits').doc(String(requesterId));
2116
+ const perTargetSnap = await perTargetRef.get();
2117
+
2118
+ let perTargetCount = 0;
2119
+ let perTargetWindowStart = now;
2120
+
2121
+ if (perTargetSnap.exists) {
2122
+ const perTargetData = perTargetSnap.data();
2123
+ perTargetCount = perTargetData.requestCount || 0;
2124
+ perTargetWindowStart = perTargetData.windowStart?.toMillis() || now;
2125
+
2126
+ // Reset window if expired
2127
+ if (now - perTargetWindowStart >= DEV_PER_TARGET_LIMIT_MS) {
2128
+ perTargetCount = 0;
2129
+ perTargetWindowStart = now;
2130
+ }
2131
+
2132
+ // Check per-target limit
2133
+ if (perTargetCount >= DEV_PER_TARGET_MAX_REQUESTS) {
2134
+ const waitMinutes = Math.ceil((perTargetWindowStart + DEV_PER_TARGET_LIMIT_MS - now) / 60000);
2135
+ return {
2136
+ allowed: false,
2137
+ message: `Developer rate limit exceeded for this target. Try again in ${waitMinutes} minutes.`
2138
+ };
2139
+ }
2140
+ }
2141
+
2142
+ // Check global limit
2143
+ if (globalRequestCount >= DEV_GLOBAL_MAX_REQUESTS) {
2144
+ const waitMinutes = Math.ceil((globalWindowStart + DEV_GLOBAL_LIMIT_MS - now) / 60000);
2145
+ return {
2146
+ allowed: false,
2147
+ message: `Developer global rate limit exceeded. Try again in ${waitMinutes} minutes.`
2148
+ };
2149
+ }
2150
+
2151
+ // Update both counters
2152
+ await globalDevRef.set({
2153
+ requestCount: globalRequestCount + 1,
2154
+ windowStart: Timestamp.fromMillis(globalWindowStart)
2155
+ }, { merge: true });
2156
+
2157
+ await perTargetRef.set({
2158
+ requestCount: perTargetCount + 1,
2159
+ windowStart: Timestamp.fromMillis(perTargetWindowStart)
2160
+ }, { merge: true });
2161
+
2162
+ return { allowed: true };
2163
+ }
2053
2164
 
2165
+ // Regular users: 1 request per 6 hours
2054
2166
  const RATE_LIMIT_MS = 6 * 60 * 60 * 1000; // 6 hours
2055
2167
  const now = Date.now();
2056
2168
 
@@ -2681,33 +2793,42 @@ const getWatchlistTriggerCounts = async (db, userId, watchlistId) => {
2681
2793
  const piCid = String(item.cid);
2682
2794
  let totalCount = 0;
2683
2795
 
2684
- // Check each of the last 7 days
2685
- for (const dateStr of dates) {
2686
- try {
2687
- const dayDoc = await db.collection('PIAlertHistoryData')
2796
+ // Check each of the last 7 days - PARALLELIZED to fix N+1 query problem
2797
+ const dayDocs = await Promise.all(
2798
+ dates.map(dateStr =>
2799
+ db.collection('PIAlertHistoryData')
2688
2800
  .doc(dateStr)
2689
- .get();
2801
+ .get()
2802
+ .catch(e => {
2803
+ console.warn(`Error fetching date ${dateStr} for PI ${piCid}: ${e.message}`);
2804
+ return null; // Return null on error instead of throwing
2805
+ })
2806
+ )
2807
+ );
2808
+
2809
+ // Process all day documents
2810
+ for (const dayDoc of dayDocs) {
2811
+ if (!dayDoc || !dayDoc.exists) continue;
2812
+
2813
+ try {
2814
+ const dayData = dayDoc.data();
2815
+ const piData = dayData[piCid];
2690
2816
 
2691
- if (dayDoc.exists) {
2692
- const dayData = dayDoc.data();
2693
- const piData = dayData[piCid];
2694
-
2695
- if (piData) {
2696
- // Check all alert types for this PI
2697
- for (const [alertType, alertData] of Object.entries(piData)) {
2698
- if (alertType === 'lastUpdated' || typeof alertData !== 'object') continue;
2699
-
2700
- // Check if triggeredFor array contains this user
2701
- const triggeredFor = alertData.triggeredFor || [];
2702
- if (Array.isArray(triggeredFor) && triggeredFor.includes(userCidStr)) {
2703
- totalCount += alertData.count || 1; // Count each trigger
2704
- }
2817
+ if (piData) {
2818
+ // Check all alert types for this PI
2819
+ for (const [alertType, alertData] of Object.entries(piData)) {
2820
+ if (alertType === 'lastUpdated' || typeof alertData !== 'object') continue;
2821
+
2822
+ // Check if triggeredFor array contains this user
2823
+ const triggeredFor = alertData.triggeredFor || [];
2824
+ if (Array.isArray(triggeredFor) && triggeredFor.includes(userCidStr)) {
2825
+ totalCount += alertData.count || 1; // Count each trigger
2705
2826
  }
2706
2827
  }
2707
2828
  }
2708
2829
  } catch (e) {
2709
- // Skip this day if error (document might not exist)
2710
- console.warn(`Error checking date ${dateStr} for PI ${piCid}: ${e.message}`);
2830
+ // Skip this day if error processing
2831
+ console.warn(`Error processing day data for PI ${piCid}: ${e.message}`);
2711
2832
  }
2712
2833
  }
2713
2834
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Security utility functions for input validation and sanitization
3
+ */
4
+
5
+ /**
6
+ * Sanitizes a CID (Customer ID) to prevent injection attacks
7
+ * @param {string|number} cid - The CID to sanitize
8
+ * @returns {string} - Sanitized CID
9
+ * @throws {Error} - If CID is invalid
10
+ */
11
+ function sanitizeCid(cid) {
12
+ if (cid === null || cid === undefined) {
13
+ throw new Error('CID is required');
14
+ }
15
+
16
+ // Convert to string and remove all non-numeric characters
17
+ const sanitized = String(cid).replace(/[^0-9]/g, '');
18
+
19
+ // Validate length and format
20
+ if (!sanitized || sanitized.length === 0) {
21
+ throw new Error('Invalid CID format: must contain at least one digit');
22
+ }
23
+
24
+ if (sanitized.length > 20) {
25
+ throw new Error('Invalid CID format: too long (max 20 digits)');
26
+ }
27
+
28
+ return sanitized;
29
+ }
30
+
31
+ /**
32
+ * Sanitizes a document ID to prevent path traversal attacks
33
+ * @param {string} docId - The document ID to sanitize
34
+ * @returns {string} - Sanitized document ID
35
+ * @throws {Error} - If document ID is invalid
36
+ */
37
+ function sanitizeDocId(docId) {
38
+ if (!docId || typeof docId !== 'string') {
39
+ throw new Error('Document ID is required and must be a string');
40
+ }
41
+
42
+ // Remove path traversal attempts and other dangerous characters
43
+ const sanitized = docId
44
+ .replace(/\.\./g, '') // Remove .. (path traversal)
45
+ .replace(/\//g, '') // Remove / (path separator)
46
+ .replace(/\\/g, '') // Remove \ (path separator)
47
+ .trim();
48
+
49
+ if (!sanitized || sanitized.length === 0) {
50
+ throw new Error('Invalid document ID: empty after sanitization');
51
+ }
52
+
53
+ if (sanitized.length > 200) {
54
+ throw new Error('Invalid document ID: too long (max 200 characters)');
55
+ }
56
+
57
+ return sanitized;
58
+ }
59
+
60
+ /**
61
+ * Validates and sanitizes a username
62
+ * @param {string} username - The username to validate
63
+ * @returns {string} - Sanitized username
64
+ * @throws {Error} - If username is invalid
65
+ */
66
+ function sanitizeUsername(username) {
67
+ if (!username || typeof username !== 'string') {
68
+ throw new Error('Username is required and must be a string');
69
+ }
70
+
71
+ const sanitized = username.trim();
72
+
73
+ if (sanitized.length === 0) {
74
+ throw new Error('Username cannot be empty');
75
+ }
76
+
77
+ if (sanitized.length > 100) {
78
+ throw new Error('Username too long (max 100 characters)');
79
+ }
80
+
81
+ // Allow alphanumeric, underscore, hyphen, and dot
82
+ if (!/^[a-zA-Z0-9._-]+$/.test(sanitized)) {
83
+ throw new Error('Username contains invalid characters');
84
+ }
85
+
86
+ return sanitized;
87
+ }
88
+
89
+ /**
90
+ * Validates batch operation size
91
+ * @param {number} size - The size of the batch operation
92
+ * @param {number} maxSize - Maximum allowed size (default: 1000)
93
+ * @throws {Error} - If batch size exceeds limit
94
+ */
95
+ function validateBatchSize(size, maxSize = 1000) {
96
+ if (typeof size !== 'number' || size < 0) {
97
+ throw new Error('Batch size must be a non-negative number');
98
+ }
99
+
100
+ if (size > maxSize) {
101
+ throw new Error(`Batch operation too large. Maximum ${maxSize} operations allowed, got ${size}`);
102
+ }
103
+ }
104
+
105
+ module.exports = {
106
+ sanitizeCid,
107
+ sanitizeDocId,
108
+ sanitizeUsername,
109
+ validateBatchSize
110
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Utility functions for request timeout protection
3
+ */
4
+
5
+ /**
6
+ * Wraps a promise with a timeout
7
+ * @param {Promise} promise - The promise to wrap
8
+ * @param {number} ms - Timeout in milliseconds (default: 10000)
9
+ * @param {string} errorMessage - Custom error message (optional)
10
+ * @returns {Promise} - Promise that rejects on timeout
11
+ */
12
+ function withTimeout(promise, ms = 10000, errorMessage = 'Request timeout') {
13
+ return Promise.race([
14
+ promise,
15
+ new Promise((_, reject) =>
16
+ setTimeout(() => reject(new Error(`${errorMessage} (${ms}ms)`)), ms)
17
+ )
18
+ ]);
19
+ }
20
+
21
+ /**
22
+ * Creates a timeout wrapper with a specific duration
23
+ * @param {number} ms - Timeout in milliseconds
24
+ * @returns {Function} - Function that wraps promises with the specified timeout
25
+ */
26
+ function createTimeoutWrapper(ms) {
27
+ return (promise, errorMessage) => withTimeout(promise, ms, errorMessage);
28
+ }
29
+
30
+ // Pre-configured timeout wrappers for common use cases
31
+ const timeouts = {
32
+ short: createTimeoutWrapper(5000), // 5 seconds
33
+ medium: createTimeoutWrapper(15000), // 15 seconds
34
+ long: createTimeoutWrapper(30000), // 30 seconds
35
+ veryLong: createTimeoutWrapper(60000) // 60 seconds
36
+ };
37
+
38
+ module.exports = {
39
+ withTimeout,
40
+ createTimeoutWrapper,
41
+ timeouts
42
+ };
@@ -6,6 +6,7 @@
6
6
  const express = require('express');
7
7
  const cors = require('cors');
8
8
  const rateLimit = require('express-rate-limit');
9
+ const { v4: uuidv4 } = require('uuid');
9
10
  const createRouter = require('./routes/index.js');
10
11
  const errorHandler = require('./middleware/error_handler.js');
11
12
 
@@ -28,8 +29,10 @@ function createApiV2App(config, dependencies) {
28
29
  const { logger } = dependencies;
29
30
 
30
31
  // Trust proxy - Required when behind a load balancer/proxy (e.g., Google Cloud Functions)
32
+ // Trust only the first proxy (the load balancer) to prevent IP spoofing
31
33
  // This allows express-rate-limit to correctly identify client IPs from X-Forwarded-For headers
32
- app.set('trust proxy', true);
34
+ // Setting to 1 means we trust only the first proxy, not all proxies (which would be insecure)
35
+ app.set('trust proxy', 1);
33
36
 
34
37
  // CORS Configuration - Restrict to specific origins
35
38
  app.use(cors({
@@ -70,6 +73,13 @@ function createApiV2App(config, dependencies) {
70
73
  app.use('/verification', authLimiter);
71
74
  app.use('/', apiLimiter);
72
75
 
76
+ // Request ID tracking middleware (for debugging and error tracking)
77
+ app.use((req, res, next) => {
78
+ req.requestId = uuidv4();
79
+ res.setHeader('X-Request-ID', req.requestId);
80
+ next();
81
+ });
82
+
73
83
  app.use(express.json());
74
84
 
75
85
  // Health Check
@@ -6,6 +6,7 @@
6
6
  module.exports = (err, req, res, next) => {
7
7
  // Log full error internally for debugging
8
8
  console.error('[Error Handler]', {
9
+ requestId: req.requestId,
9
10
  error: err.message,
10
11
  stack: err.stack,
11
12
  path: req.path,