bulltrackers-module 1.0.621 → 1.0.623
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/functions/api-v2/helpers/data-fetchers/firestore.js +166 -49
- package/functions/api-v2/helpers/security_utils.js +110 -0
- package/functions/api-v2/helpers/timeout_utils.js +42 -0
- package/functions/api-v2/index.js +12 -0
- package/functions/api-v2/middleware/error_handler.js +1 -0
- package/functions/api-v2/middleware/identity_middleware.js +2 -1
- package/functions/api-v2/routes/alerts.js +108 -40
- package/functions/api-v2/routes/notifications.js +40 -12
- package/functions/api-v2/routes/popular_investors.js +70 -26
- package/functions/api-v2/routes/profile.js +45 -13
- package/functions/api-v2/routes/settings.js +52 -14
- package/functions/api-v2/routes/sync.js +48 -9
- package/functions/api-v2/routes/watchlists.js +93 -26
- package/package.json +1 -1
|
@@ -1,27 +1,53 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
+
const { z } = require('zod');
|
|
2
3
|
const { manageNotificationPreferences, isDeveloper, sendTestAlert } = require('../helpers/data-fetchers/firestore.js');
|
|
3
4
|
|
|
4
5
|
const router = express.Router();
|
|
5
6
|
|
|
7
|
+
// Input validation schemas
|
|
8
|
+
const notificationPreferencesSchema = z.object({
|
|
9
|
+
email: z.boolean().optional(),
|
|
10
|
+
push: z.boolean().optional(),
|
|
11
|
+
sms: z.boolean().optional(),
|
|
12
|
+
alertTypes: z.record(z.boolean()).optional(),
|
|
13
|
+
thresholds: z.record(z.any()).optional()
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const devOverrideSchema = z.object({
|
|
17
|
+
enabled: z.boolean(),
|
|
18
|
+
impersonateCid: z.union([z.string().regex(/^\d+$/), z.number().int().positive()]).optional(),
|
|
19
|
+
fakeCopiedPIs: z.array(z.union([z.string().regex(/^\d+$/), z.number().int().positive()])).optional()
|
|
20
|
+
});
|
|
21
|
+
|
|
6
22
|
// GET /settings/notifications
|
|
7
|
-
router.get('/notifications', async (req, res) => {
|
|
23
|
+
router.get('/notifications', async (req, res, next) => {
|
|
8
24
|
try {
|
|
9
25
|
const { db } = req.dependencies;
|
|
10
26
|
const data = await manageNotificationPreferences(db, req.targetUserId, 'get');
|
|
11
27
|
res.json({ success: true, data });
|
|
12
28
|
} catch (error) {
|
|
13
|
-
|
|
29
|
+
next(error);
|
|
14
30
|
}
|
|
15
31
|
});
|
|
16
32
|
|
|
17
33
|
// PUT /settings/notifications
|
|
18
|
-
router.put('/notifications', async (req, res) => {
|
|
34
|
+
router.put('/notifications', async (req, res, next) => {
|
|
19
35
|
try {
|
|
36
|
+
// Validate input
|
|
37
|
+
const validated = notificationPreferencesSchema.parse(req.body);
|
|
38
|
+
|
|
20
39
|
const { db } = req.dependencies;
|
|
21
|
-
const result = await manageNotificationPreferences(db, req.targetUserId, 'update',
|
|
40
|
+
const result = await manageNotificationPreferences(db, req.targetUserId, 'update', validated);
|
|
22
41
|
res.json(result);
|
|
23
42
|
} catch (error) {
|
|
24
|
-
|
|
43
|
+
if (error instanceof z.ZodError) {
|
|
44
|
+
return res.status(400).json({
|
|
45
|
+
success: false,
|
|
46
|
+
error: 'Invalid input',
|
|
47
|
+
details: error.errors
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
next(error);
|
|
25
51
|
}
|
|
26
52
|
});
|
|
27
53
|
|
|
@@ -33,27 +59,39 @@ router.get('/dev/override', async (req, res) => {
|
|
|
33
59
|
|
|
34
60
|
const doc = await db.collection('dev_overrides').doc(req.targetUserId).get();
|
|
35
61
|
res.json({ success: true, data: doc.exists ? doc.data() : { enabled: false } });
|
|
36
|
-
} catch (e) {
|
|
62
|
+
} catch (e) { next(e); }
|
|
37
63
|
});
|
|
38
64
|
|
|
39
65
|
// POST /settings/dev/override
|
|
40
|
-
router.post('/dev/override', async (req, res) => {
|
|
66
|
+
router.post('/dev/override', async (req, res, next) => {
|
|
41
67
|
try {
|
|
42
|
-
|
|
43
|
-
const
|
|
68
|
+
// Validate input
|
|
69
|
+
const validated = devOverrideSchema.parse(req.body);
|
|
44
70
|
|
|
45
|
-
|
|
71
|
+
const { db } = req.dependencies;
|
|
72
|
+
if (!(await isDeveloper(db, req.targetUserId))) {
|
|
73
|
+
const error = new Error("Unauthorized");
|
|
74
|
+
error.status = 403;
|
|
75
|
+
return next(error);
|
|
76
|
+
}
|
|
46
77
|
|
|
47
78
|
await db.collection('dev_overrides').doc(req.targetUserId).set({
|
|
48
|
-
enabled,
|
|
49
|
-
impersonateCid,
|
|
50
|
-
fakeCopiedPIs: fakeCopiedPIs
|
|
79
|
+
enabled: validated.enabled,
|
|
80
|
+
impersonateCid: validated.impersonateCid ? String(validated.impersonateCid) : undefined,
|
|
81
|
+
fakeCopiedPIs: validated.fakeCopiedPIs ? validated.fakeCopiedPIs.map(cid => String(cid)) : [],
|
|
51
82
|
updatedAt: new Date()
|
|
52
83
|
}, { merge: true });
|
|
53
84
|
|
|
54
85
|
res.json({ success: true, message: "Dev override updated" });
|
|
55
86
|
} catch (error) {
|
|
56
|
-
|
|
87
|
+
if (error instanceof z.ZodError) {
|
|
88
|
+
return res.status(400).json({
|
|
89
|
+
success: false,
|
|
90
|
+
error: 'Invalid input',
|
|
91
|
+
details: error.errors
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
next(error);
|
|
57
95
|
}
|
|
58
96
|
});
|
|
59
97
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
+
const { z } = require('zod');
|
|
2
3
|
const { dispatchSyncRequest } = require('../helpers/task_engine_helper.js');
|
|
3
4
|
const {
|
|
4
5
|
checkSyncRateLimits,
|
|
@@ -10,22 +11,58 @@ const {
|
|
|
10
11
|
checkDataStatus,
|
|
11
12
|
checkPopularInvestorDataStatus
|
|
12
13
|
} = require('../helpers/data-fetchers/firestore.js');
|
|
14
|
+
const { sanitizeCid } = require('../helpers/security_utils.js');
|
|
13
15
|
|
|
14
16
|
const router = express.Router();
|
|
15
17
|
|
|
18
|
+
// Input validation schemas
|
|
19
|
+
const syncRequestSchema = z.object({
|
|
20
|
+
targetId: z.union([
|
|
21
|
+
z.string().regex(/^\d+$/, 'Invalid CID format'),
|
|
22
|
+
z.number().int().positive()
|
|
23
|
+
]).optional()
|
|
24
|
+
}).transform(data => ({
|
|
25
|
+
...data,
|
|
26
|
+
targetId: data.targetId ? String(data.targetId) : undefined
|
|
27
|
+
}));
|
|
28
|
+
|
|
16
29
|
// Helper function for sync request logic (shared between /sync/request and /user/:userId/sync)
|
|
17
|
-
const handleSyncRequest = async (req, res) => {
|
|
30
|
+
const handleSyncRequest = async (req, res, next) => {
|
|
18
31
|
try {
|
|
19
32
|
const { pubsub, db } = req.dependencies;
|
|
20
|
-
// targetId can be passed (for PIs) or default to self
|
|
21
|
-
// For /user/:userId/sync, targetId comes from URL param
|
|
22
|
-
// For /sync/request, targetId comes from body or req.targetUserId
|
|
23
|
-
const targetId = req.body.targetId || req.params.userId || req.targetUserId;
|
|
24
33
|
|
|
25
|
-
//
|
|
34
|
+
// Validate and sanitize input
|
|
35
|
+
let targetId;
|
|
36
|
+
if (req.params.userId) {
|
|
37
|
+
// From URL param - sanitize directly
|
|
38
|
+
targetId = sanitizeCid(req.params.userId);
|
|
39
|
+
} else if (req.body.targetId) {
|
|
40
|
+
// From body - validate with schema
|
|
41
|
+
const validated = syncRequestSchema.parse({ targetId: req.body.targetId });
|
|
42
|
+
targetId = validated.targetId || sanitizeCid(req.targetUserId);
|
|
43
|
+
} else {
|
|
44
|
+
// Default to authenticated user
|
|
45
|
+
targetId = sanitizeCid(req.targetUserId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 1. Rate Limits with developer audit logging
|
|
26
49
|
const isDev = await isDeveloper(db, req.targetUserId);
|
|
27
50
|
const limit = await checkSyncRateLimits(db, targetId, req.targetUserId, isDev);
|
|
28
|
-
|
|
51
|
+
|
|
52
|
+
// Log developer activity for audit trail
|
|
53
|
+
if (isDev) {
|
|
54
|
+
console.log(`[Sync Rate Limit] Developer ${req.targetUserId} sync request for target ${targetId}`, {
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
developerCid: req.targetUserId,
|
|
57
|
+
targetCid: targetId,
|
|
58
|
+
ip: req.ip,
|
|
59
|
+
allowed: limit.allowed
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!limit.allowed) {
|
|
64
|
+
return res.status(429).json({ error: limit.message });
|
|
65
|
+
}
|
|
29
66
|
|
|
30
67
|
// 2. Detect User Types (needed for validation)
|
|
31
68
|
const [isSignedIn, isPI] = await Promise.all([
|
|
@@ -156,7 +193,7 @@ const handleSyncRequest = async (req, res) => {
|
|
|
156
193
|
message: `Sync dispatched for ${isSignedIn && isPI ? 'both user types' : isSignedIn ? 'signed-in user' : 'popular investor'}`
|
|
157
194
|
});
|
|
158
195
|
} catch (error) {
|
|
159
|
-
|
|
196
|
+
next(error);
|
|
160
197
|
}
|
|
161
198
|
};
|
|
162
199
|
|
|
@@ -167,7 +204,9 @@ router.post('/request', handleSyncRequest);
|
|
|
167
204
|
router.get('/status/:targetId', async (req, res, next) => {
|
|
168
205
|
try {
|
|
169
206
|
const { db } = req.dependencies;
|
|
170
|
-
|
|
207
|
+
// Sanitize targetId from URL param
|
|
208
|
+
const targetId = sanitizeCid(req.params.targetId);
|
|
209
|
+
const status = await getSyncStatus(db, targetId);
|
|
171
210
|
res.json({ success: true, data: status });
|
|
172
211
|
} catch (error) {
|
|
173
212
|
next(error);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
+
const { z } = require('zod');
|
|
2
3
|
const {
|
|
3
4
|
manageUserWatchlist,
|
|
4
5
|
latestUserCentricSnapshot,
|
|
@@ -11,11 +12,29 @@ const {
|
|
|
11
12
|
getWatchlistTriggerCounts,
|
|
12
13
|
subscribeToAllWatchlistPIs
|
|
13
14
|
} = require('../helpers/data-fetchers/firestore.js');
|
|
15
|
+
const { sanitizeDocId } = require('../helpers/security_utils.js');
|
|
14
16
|
|
|
15
17
|
const router = express.Router();
|
|
16
18
|
|
|
19
|
+
// Input validation schemas
|
|
20
|
+
const watchlistManageSchema = z.object({
|
|
21
|
+
instruction: z.enum(['create', 'update', 'delete', 'add_item', 'remove_item', 'update_item']),
|
|
22
|
+
payload: z.object({
|
|
23
|
+
watchlistId: z.string().optional(),
|
|
24
|
+
name: z.string().max(200).optional(),
|
|
25
|
+
description: z.string().max(1000).optional(),
|
|
26
|
+
items: z.array(z.any()).optional(),
|
|
27
|
+
item: z.any().optional()
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const watchlistSubscribeSchema = z.object({
|
|
32
|
+
alertTypes: z.array(z.string()).optional(),
|
|
33
|
+
thresholds: z.record(z.any()).optional()
|
|
34
|
+
});
|
|
35
|
+
|
|
17
36
|
// GET /watchlists/all
|
|
18
|
-
router.get('/all', async (req, res) => {
|
|
37
|
+
router.get('/all', async (req, res, next) => {
|
|
19
38
|
try {
|
|
20
39
|
const { db } = req.dependencies;
|
|
21
40
|
const watchlists = await latestUserCentricSnapshot(db, req.targetUserId, 'watchlists', 'watchlist', 'SignedInUsers', null);
|
|
@@ -36,9 +55,12 @@ router.post('/auto-generate', async (req, res, next) => {
|
|
|
36
55
|
});
|
|
37
56
|
|
|
38
57
|
// GET /watchlists/public - Must be before /:id route to avoid route conflict
|
|
39
|
-
router.get('/public', async (req, res) => {
|
|
58
|
+
router.get('/public', async (req, res, next) => {
|
|
40
59
|
try {
|
|
41
|
-
|
|
60
|
+
// Validate and sanitize query parameters
|
|
61
|
+
const limit = req.query.limit ? Math.min(Math.max(parseInt(req.query.limit) || 10, 1), 100) : 10;
|
|
62
|
+
const offset = req.query.offset ? Math.max(parseInt(req.query.offset) || 0, 0) : 0;
|
|
63
|
+
const data = await fetchPublicWatchlists(req.dependencies.db, limit, offset);
|
|
42
64
|
res.json({ success: true, data });
|
|
43
65
|
} catch (e) { next(e); }
|
|
44
66
|
});
|
|
@@ -47,9 +69,14 @@ router.get('/public', async (req, res) => {
|
|
|
47
69
|
router.get('/:id', async (req, res, next) => {
|
|
48
70
|
try {
|
|
49
71
|
const { db } = req.dependencies;
|
|
50
|
-
|
|
72
|
+
// Sanitize watchlist ID
|
|
73
|
+
const id = sanitizeDocId(req.params.id);
|
|
51
74
|
const doc = await latestUserCentricSnapshot(db, req.targetUserId, 'watchlists', 'watchlist', 'SignedInUsers', id);
|
|
52
|
-
if (!doc)
|
|
75
|
+
if (!doc) {
|
|
76
|
+
const error = new Error("Watchlist not found");
|
|
77
|
+
error.status = 404;
|
|
78
|
+
return next(error);
|
|
79
|
+
}
|
|
53
80
|
res.json({ success: true, data: doc });
|
|
54
81
|
} catch (error) {
|
|
55
82
|
next(error);
|
|
@@ -57,24 +84,37 @@ router.get('/:id', async (req, res, next) => {
|
|
|
57
84
|
});
|
|
58
85
|
|
|
59
86
|
// GET /watchlists/:id/rankings-check (Rec 10)
|
|
60
|
-
router.get('/:id/rankings-check', async (req, res) => {
|
|
87
|
+
router.get('/:id/rankings-check', async (req, res, next) => {
|
|
61
88
|
try {
|
|
62
89
|
const { db } = req.dependencies;
|
|
63
|
-
|
|
90
|
+
// Sanitize watchlist ID
|
|
91
|
+
const id = sanitizeDocId(req.params.id);
|
|
64
92
|
|
|
65
93
|
// Fetch watchlist
|
|
66
94
|
const wl = await latestUserCentricSnapshot(db, req.targetUserId, 'watchlists', 'watchlist', 'SignedInUsers', id);
|
|
67
|
-
if (!wl)
|
|
95
|
+
if (!wl) {
|
|
96
|
+
const error = new Error("Watchlist not found");
|
|
97
|
+
error.status = 404;
|
|
98
|
+
return next(error);
|
|
99
|
+
}
|
|
68
100
|
|
|
69
|
-
|
|
70
|
-
|
|
101
|
+
// Parallelize PI checks to avoid N+1 queries
|
|
102
|
+
const items = wl.items || [];
|
|
103
|
+
const checkPromises = items.map(async (item) => {
|
|
71
104
|
try {
|
|
72
105
|
await fetchPopularInvestorMasterList(db, String(item.cid));
|
|
73
|
-
|
|
106
|
+
return { cid: item.cid, exists: true };
|
|
74
107
|
} catch {
|
|
75
|
-
|
|
108
|
+
return { cid: item.cid, exists: false };
|
|
76
109
|
}
|
|
77
|
-
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const checkResults = await Promise.all(checkPromises);
|
|
113
|
+
const results = {};
|
|
114
|
+
checkResults.forEach(result => {
|
|
115
|
+
results[result.cid] = result.exists;
|
|
116
|
+
});
|
|
117
|
+
|
|
78
118
|
res.json({ success: true, data: results });
|
|
79
119
|
} catch (error) {
|
|
80
120
|
next(error);
|
|
@@ -84,11 +124,20 @@ router.get('/:id/rankings-check', async (req, res) => {
|
|
|
84
124
|
// POST /watchlists/manage (Unified)
|
|
85
125
|
router.post('/manage', async (req, res, next) => {
|
|
86
126
|
try {
|
|
127
|
+
// Validate input
|
|
128
|
+
const validatedData = watchlistManageSchema.parse(req.body);
|
|
129
|
+
|
|
87
130
|
const { db } = req.dependencies;
|
|
88
|
-
const
|
|
89
|
-
const result = await manageUserWatchlist(db, req.targetUserId, instruction, payload);
|
|
131
|
+
const result = await manageUserWatchlist(db, req.targetUserId, validatedData.instruction, validatedData.payload);
|
|
90
132
|
res.json(result);
|
|
91
133
|
} catch (error) {
|
|
134
|
+
if (error instanceof z.ZodError) {
|
|
135
|
+
return res.status(400).json({
|
|
136
|
+
success: false,
|
|
137
|
+
error: 'Invalid input',
|
|
138
|
+
details: error.errors
|
|
139
|
+
});
|
|
140
|
+
}
|
|
92
141
|
next(error);
|
|
93
142
|
}
|
|
94
143
|
});
|
|
@@ -96,53 +145,71 @@ router.post('/manage', async (req, res, next) => {
|
|
|
96
145
|
// Public & Copy Routes (Existing)
|
|
97
146
|
router.post('/:id/publish', async (req, res, next) => {
|
|
98
147
|
try {
|
|
99
|
-
|
|
148
|
+
// Sanitize watchlist ID
|
|
149
|
+
const id = sanitizeDocId(req.params.id);
|
|
150
|
+
const result = await publishWatchlistVersion(req.dependencies.db, req.targetUserId, id);
|
|
100
151
|
res.json(result);
|
|
101
152
|
} catch (e) { next(e); }
|
|
102
153
|
});
|
|
103
154
|
|
|
104
|
-
router.post('/:id/copy', async (req, res) => {
|
|
155
|
+
router.post('/:id/copy', async (req, res, next) => {
|
|
105
156
|
try {
|
|
106
|
-
|
|
157
|
+
// Sanitize watchlist ID
|
|
158
|
+
const id = sanitizeDocId(req.params.id);
|
|
159
|
+
const version = req.body.version ? sanitizeDocId(String(req.body.version)) : undefined;
|
|
160
|
+
const result = await copyWatchlist(req.dependencies.db, req.targetUserId, id, version);
|
|
107
161
|
res.json(result);
|
|
108
162
|
} catch (e) { next(e); }
|
|
109
163
|
});
|
|
110
164
|
|
|
111
165
|
router.get('/:id/versions', async (req, res, next) => {
|
|
112
166
|
try {
|
|
113
|
-
|
|
167
|
+
// Sanitize watchlist ID
|
|
168
|
+
const id = sanitizeDocId(req.params.id);
|
|
169
|
+
const data = await fetchWatchlistVersions(req.dependencies.db, id);
|
|
114
170
|
res.json({ success: true, data });
|
|
115
171
|
} catch (e) { next(e); }
|
|
116
172
|
});
|
|
117
173
|
|
|
118
174
|
// GET /watchlists/:id/trigger-counts - Get alert trigger counts for watchlist PIs
|
|
119
|
-
router.get('/:id/trigger-counts', async (req, res) => {
|
|
175
|
+
router.get('/:id/trigger-counts', async (req, res, next) => {
|
|
120
176
|
try {
|
|
121
177
|
const { db } = req.dependencies;
|
|
122
|
-
|
|
178
|
+
// Sanitize watchlist ID
|
|
179
|
+
const id = sanitizeDocId(req.params.id);
|
|
123
180
|
const result = await getWatchlistTriggerCounts(db, req.targetUserId, id);
|
|
124
181
|
res.json({ success: true, ...result });
|
|
125
182
|
} catch (error) {
|
|
126
183
|
if (error.message === "Watchlist not found") {
|
|
127
184
|
error.status = 404;
|
|
128
185
|
}
|
|
129
|
-
|
|
186
|
+
next(error);
|
|
130
187
|
}
|
|
131
188
|
});
|
|
132
189
|
|
|
133
190
|
// POST /watchlists/:id/subscribe-all - Subscribe to all PIs in a watchlist
|
|
134
191
|
router.post('/:id/subscribe-all', async (req, res, next) => {
|
|
135
192
|
try {
|
|
193
|
+
// Validate input
|
|
194
|
+
const validatedData = watchlistSubscribeSchema.parse(req.body);
|
|
195
|
+
// Sanitize watchlist ID
|
|
196
|
+
const id = sanitizeDocId(req.params.id);
|
|
197
|
+
|
|
136
198
|
const { db } = req.dependencies;
|
|
137
|
-
const
|
|
138
|
-
const { alertTypes, thresholds } = req.body;
|
|
139
|
-
const result = await subscribeToAllWatchlistPIs(db, req.targetUserId, id, alertTypes, thresholds);
|
|
199
|
+
const result = await subscribeToAllWatchlistPIs(db, req.targetUserId, id, validatedData.alertTypes, validatedData.thresholds);
|
|
140
200
|
res.json(result);
|
|
141
201
|
} catch (error) {
|
|
202
|
+
if (error instanceof z.ZodError) {
|
|
203
|
+
return res.status(400).json({
|
|
204
|
+
success: false,
|
|
205
|
+
error: 'Invalid input',
|
|
206
|
+
details: error.errors
|
|
207
|
+
});
|
|
208
|
+
}
|
|
142
209
|
if (error.message === "Watchlist not found") {
|
|
143
210
|
error.status = 404;
|
|
144
211
|
}
|
|
145
|
-
|
|
212
|
+
next(error);
|
|
146
213
|
}
|
|
147
214
|
});
|
|
148
215
|
|