bulltrackers-module 1.0.616 → 1.0.618

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
2
  const { FieldValue } = require('@google-cloud/firestore');
3
3
  const { dispatchSyncRequest } = require('../task_engine_helper.js');
4
+ const crypto = require('crypto');
4
5
 
5
6
  // 1. Fetch latest stored snapshots of user data from a user-centric collection
6
7
 
@@ -525,10 +526,43 @@ const fetchUserVerificationData = async (firestore, userId) => {
525
526
  }
526
527
  };
527
528
 
529
+ /**
530
+ * Hash email for use as document ID in lookup collection
531
+ * Uses SHA-256 for consistent hashing
532
+ */
533
+ const hashEmail = (email) => {
534
+ const normalizedEmail = String(email).toLowerCase().trim();
535
+ return crypto.createHash('sha256').update(normalizedEmail).digest('hex');
536
+ };
537
+
538
+ /**
539
+ * Create or update email-to-CID lookup document for O(1) lookups
540
+ * This is called when an email is verified or when migration finds a match
541
+ */
542
+ const createEmailLookup = async (firestore, email, cid) => {
543
+ try {
544
+ const hashedEmail = hashEmail(email);
545
+ const lookupRef = firestore.collection('sys_user_lookup').doc(hashedEmail);
546
+
547
+ await lookupRef.set({
548
+ cid: Number(cid),
549
+ emailHash: hashedEmail,
550
+ createdAt: FieldValue.serverTimestamp(),
551
+ updatedAt: FieldValue.serverTimestamp(),
552
+ }, { merge: true });
553
+
554
+ console.log(`[createEmailLookup] Created lookup document for email hash ${hashedEmail} -> CID ${cid}`);
555
+ } catch (error) {
556
+ console.error(`[createEmailLookup] Error creating lookup for ${email}:`, error);
557
+ // Don't throw - lookup creation is non-critical, fallback will work
558
+ }
559
+ };
560
+
528
561
  /**
529
562
  * Lookup CID by email (server-side only for security)
530
- * Searches through all SignedInUsers verification documents to find matching email
531
- * Returns only the CID and basic verification status - no email exposure
563
+ *
564
+ * PERFORMANCE: Uses O(1) lookup collection first, falls back to O(N) search if not found
565
+ * MIGRATION: When fallback finds a match, creates lookup document for future O(1) access
532
566
  *
533
567
  * NOTE: Multiple Firebase accounts (different UIDs) can access the same CID
534
568
  * if their emails are in the verification document's email array.
@@ -541,8 +575,60 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
541
575
  try {
542
576
  // Normalize email for comparison
543
577
  const normalizedEmail = String(userEmail).toLowerCase().trim();
578
+ const hashedEmail = hashEmail(normalizedEmail);
579
+
580
+ // STEP 1: Try O(1) lookup from denormalized collection
581
+ const lookupRef = firestore.collection('sys_user_lookup').doc(hashedEmail);
582
+ const lookupDoc = await lookupRef.get();
583
+
584
+ if (lookupDoc.exists) {
585
+ const lookupData = lookupDoc.data();
586
+ const cid = lookupData.cid;
587
+
588
+ // Fetch verification data to return full user info
589
+ const verificationRef = firestore.collection('SignedInUsers').doc(String(cid)).collection('verification').doc('data');
590
+ const verificationDoc = await verificationRef.get();
591
+
592
+ if (verificationDoc.exists) {
593
+ const verificationData = verificationDoc.data();
594
+
595
+ // Verify email is still in the verification document (safety check)
596
+ const emails = Array.isArray(verificationData.email)
597
+ ? verificationData.email
598
+ : (verificationData.email ? [verificationData.email] : []);
599
+ const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
600
+
601
+ if (normalizedEmails.includes(normalizedEmail)) {
602
+ // Optional: Validate Firebase UID if provided
603
+ if (firebaseUid && verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
604
+ if (!verificationData.firebaseUids.includes(firebaseUid)) {
605
+ console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${cid} but Firebase UID ${firebaseUid} not in firebaseUids array`);
606
+ }
607
+ }
608
+
609
+ // Return result from O(1) lookup
610
+ return {
611
+ cid: verificationData.etoroCID || Number(cid),
612
+ accountSetupComplete: verificationData.accountSetupComplete || false,
613
+ etoroUsername: verificationData.etoroUsername,
614
+ displayName: verificationData.displayName,
615
+ photoURL: verificationData.photoURL,
616
+ verifiedAt: verificationData.verifiedAt,
617
+ setupCompletedAt: verificationData.setupCompletedAt,
618
+ };
619
+ } else {
620
+ // Email not in verification doc - lookup is stale, will fall through to migration
621
+ console.warn(`[lookupCidByEmail] Lookup document exists but email ${normalizedEmail} not in verification doc for CID ${cid}`);
622
+ }
623
+ }
624
+ }
625
+
626
+ // STEP 2: Fallback to O(N) search (migration path)
627
+ // This happens when:
628
+ // 1. Lookup document doesn't exist yet (new user or not migrated)
629
+ // 2. Lookup document exists but email not in verification (stale data)
630
+ console.log(`[lookupCidByEmail] Lookup document not found for ${normalizedEmail}, performing O(N) search (migration)`);
544
631
 
545
- // Get all SignedInUsers documents
546
632
  const signedInUsersSnapshot = await firestore.collection('SignedInUsers').get();
547
633
 
548
634
  // Search through each CID's verification document
@@ -559,18 +645,24 @@ const lookupCidByEmail = async (firestore, userEmail, firebaseUid = null) => {
559
645
  // Case-insensitive email comparison
560
646
  const normalizedEmails = emails.map(e => String(e).toLowerCase().trim());
561
647
  if (normalizedEmails.includes(normalizedEmail)) {
562
- // Optional: If Firebase UID is provided, validate it's in the firebaseUids array (if it exists)
563
- // This provides additional security but doesn't block access if UID tracking isn't set up yet
648
+ const cid = verificationData.etoroCID || Number(userDoc.id);
649
+
650
+ // Optional: Validate Firebase UID if provided
564
651
  if (firebaseUid && verificationData.firebaseUids && Array.isArray(verificationData.firebaseUids)) {
565
652
  if (!verificationData.firebaseUids.includes(firebaseUid)) {
566
- // UID not in list - log warning but still allow access (email is the source of truth)
567
- console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${userDoc.id} but Firebase UID ${firebaseUid} not in firebaseUids array`);
653
+ console.warn(`[lookupCidByEmail] Email ${userEmail} matches CID ${cid} but Firebase UID ${firebaseUid} not in firebaseUids array`);
568
654
  }
569
655
  }
570
656
 
571
- // Found match - return only CID and verification status (no email)
657
+ // STEP 3: Create lookup document for future O(1) access (migration)
658
+ // Create lookup for all emails in the verification document to handle multiple accounts
659
+ for (const email of normalizedEmails) {
660
+ await createEmailLookup(firestore, email, cid);
661
+ }
662
+
663
+ // Return result
572
664
  return {
573
- cid: verificationData.etoroCID || Number(userDoc.id),
665
+ cid: cid,
574
666
  accountSetupComplete: verificationData.accountSetupComplete || false,
575
667
  etoroUsername: verificationData.etoroUsername,
576
668
  displayName: verificationData.displayName,
@@ -1700,6 +1792,14 @@ const finalizeVerification = async (db, pubsub, userId, username) => {
1700
1792
  accountSetupComplete: false, // Will be set to true by frontend completeAccountSetup
1701
1793
  createdAt: FieldValue.serverTimestamp(),
1702
1794
  }, { merge: true });
1795
+
1796
+ // 3. Create lookup documents for all emails (O(1) lookup optimization)
1797
+ // This ensures future lookups are fast even if emails are added later
1798
+ for (const email of emails) {
1799
+ if (email) {
1800
+ await createEmailLookup(db, email, realCID);
1801
+ }
1802
+ }
1703
1803
 
1704
1804
  // 4. Trigger Downstream Systems via Task Engine
1705
1805
  if (pubsub) {
@@ -2624,6 +2724,8 @@ module.exports = {
2624
2724
  fetchPopularInvestorMasterList,
2625
2725
  isDeveloper,
2626
2726
  lookupCidByEmail,
2727
+ createEmailLookup,
2728
+ hashEmail,
2627
2729
  manageUserWatchlist,
2628
2730
  fetchUserVerificationData,
2629
2731
  requestPopularInvestorAddition,
@@ -104,6 +104,15 @@ const resolveUserIdentity = async (req, res, next) => {
104
104
  // 1. Identify the requested user ID (from params/headers/body)
105
105
  const requestedUserId = req.query.userCid || req.body.userCid || req.headers['x-user-cid'];
106
106
 
107
+ // SECURITY: For private routes, require Firebase Auth to prevent IDOR attacks
108
+ if (!isPublic && !authenticatedUserCid && !hasFirebaseAuth) {
109
+ // Private route without authentication - reject immediately
110
+ console.warn(`[Identity] Security violation: Private route ${req.path} accessed without Firebase Auth`);
111
+ return res.status(401).json({
112
+ error: "Authentication required. Please provide a valid Firebase ID token in the Authorization header."
113
+ });
114
+ }
115
+
107
116
  // For public routes, userCid is optional
108
117
  if (!requestedUserId && !isPublic && !hasFirebaseAuth) {
109
118
  return res.status(400).json({ error: "Missing user identification (userCid)" });
@@ -150,24 +159,30 @@ const resolveUserIdentity = async (req, res, next) => {
150
159
  req.targetUserId = authenticatedUserCid;
151
160
  req.isImpersonating = false;
152
161
  } else if (requestedUserId) {
153
- // No Firebase Auth but CID provided - legacy behavior (for backward compatibility)
154
- // WARNING: This is less secure but may be needed for some legacy clients
155
- // Consider requiring Firebase Auth for all authenticated routes
162
+ // SECURITY FIX: Legacy mode only allowed for public routes
163
+ // For private routes, Firebase Auth is REQUIRED to prevent IDOR attacks
156
164
  if (!isPublic) {
157
- console.warn(`[Identity] No Firebase Auth but CID ${requestedUserId} provided - using legacy mode`);
165
+ // Private route without authentication - reject the request
166
+ console.warn(`[Identity] Security violation: Private route accessed without Firebase Auth. Requested CID: ${requestedUserId}`);
167
+ return res.status(401).json({
168
+ error: "Authentication required. Please provide a valid Firebase ID token in the Authorization header."
169
+ });
158
170
  }
171
+
172
+ // Legacy mode only for public routes (backward compatibility)
173
+ // Public routes don't expose sensitive data, so this is acceptable
159
174
  req.actualUserId = requestedUserId;
160
175
  req.targetUserId = requestedUserId;
161
176
  req.isImpersonating = false;
162
177
 
163
- // Check for developer impersonation (legacy mode)
178
+ // Check for developer impersonation (legacy mode - only for public routes)
164
179
  const impersonateId = req.headers['x-impersonate-cid'] || req.query.impersonateCid;
165
180
  if (impersonateId && impersonateId !== requestedUserId) {
166
181
  const isDev = await isDeveloper(db, requestedUserId);
167
182
  if (isDev) {
168
183
  req.targetUserId = impersonateId;
169
184
  req.isImpersonating = true;
170
- console.log(`[Identity] Dev ${requestedUserId} is impersonating ${impersonateId}`);
185
+ console.log(`[Identity] Dev ${requestedUserId} is impersonating ${impersonateId} (public route)`);
171
186
  } else {
172
187
  console.warn(`[Identity] Unauthorized impersonation attempt by ${requestedUserId}`);
173
188
  }
@@ -1,5 +1,5 @@
1
1
  const express = require('express');
2
- const { initiateVerification, finalizeVerification, fetchUserVerificationData, lookupCidByEmail } = require('../helpers/data-fetchers/firestore.js');
2
+ const { initiateVerification, finalizeVerification, fetchUserVerificationData, lookupCidByEmail, createEmailLookup } = require('../helpers/data-fetchers/firestore.js');
3
3
  const { requireFirebaseAuth } = require('../middleware/firebase_auth_middleware.js');
4
4
 
5
5
  const router = express.Router();
@@ -79,4 +79,57 @@ router.post('/finalize', async (req, res) => {
79
79
  }
80
80
  });
81
81
 
82
+ // POST /verification/update-lookup
83
+ // Updates email-to-CID lookup documents when emails are added to verification
84
+ // Called by frontend after updating verification document with new emails
85
+ // This ensures O(1) lookups work immediately without waiting for migration
86
+ router.post('/update-lookup', requireFirebaseAuth, async (req, res) => {
87
+ try {
88
+ const { db } = req.dependencies;
89
+ const { cid, emails } = req.body;
90
+
91
+ if (!cid || !Array.isArray(emails) || emails.length === 0) {
92
+ return res.status(400).json({
93
+ success: false,
94
+ error: 'CID and emails array are required'
95
+ });
96
+ }
97
+
98
+ // Verify the authenticated user owns this CID
99
+ const userEmail = req.firebaseUser?.email;
100
+ if (userEmail) {
101
+ const userData = await lookupCidByEmail(db, userEmail);
102
+ if (!userData || String(userData.cid) !== String(cid)) {
103
+ return res.status(403).json({
104
+ success: false,
105
+ error: 'Unauthorized: You can only update lookup for your own CID'
106
+ });
107
+ }
108
+ }
109
+
110
+ // Create lookup documents for all emails
111
+ const results = [];
112
+ for (const email of emails) {
113
+ if (email) {
114
+ try {
115
+ await createEmailLookup(db, email, cid);
116
+ results.push({ email, success: true });
117
+ } catch (error) {
118
+ console.error(`[update-lookup] Failed to create lookup for ${email}:`, error);
119
+ results.push({ email, success: false, error: error.message });
120
+ }
121
+ }
122
+ }
123
+
124
+ res.json({
125
+ success: true,
126
+ message: `Updated ${results.filter(r => r.success).length} lookup document(s)`,
127
+ results
128
+ });
129
+ } catch (error) {
130
+ console.error('[Verification Update Lookup] Error:', error);
131
+ res.status(500).json({ success: false, error: error.message });
132
+ }
133
+ });
134
+
82
135
  module.exports = router;
@@ -35,6 +35,14 @@ router.post('/auto-generate', async (req, res) => {
35
35
  }
36
36
  });
37
37
 
38
+ // GET /watchlists/public - Must be before /:id route to avoid route conflict
39
+ router.get('/public', async (req, res) => {
40
+ try {
41
+ const data = await fetchPublicWatchlists(req.dependencies.db, req.query.limit, req.query.offset);
42
+ res.json({ success: true, data });
43
+ } catch (e) { res.status(500).json({ error: e.message }); }
44
+ });
45
+
38
46
  // GET /watchlists/:id (Rec 12)
39
47
  router.get('/:id', async (req, res) => {
40
48
  try {
@@ -86,13 +94,6 @@ router.post('/manage', async (req, res) => {
86
94
  });
87
95
 
88
96
  // Public & Copy Routes (Existing)
89
- router.get('/public', async (req, res) => {
90
- try {
91
- const data = await fetchPublicWatchlists(req.dependencies.db, req.query.limit, req.query.offset);
92
- res.json({ success: true, data });
93
- } catch (e) { res.status(500).json({ error: e.message }); }
94
- });
95
-
96
97
  router.post('/:id/publish', async (req, res) => {
97
98
  try {
98
99
  const result = await publishWatchlistVersion(req.dependencies.db, req.targetUserId, req.params.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.616",
3
+ "version": "1.0.618",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [