bulltrackers-module 1.0.733 → 1.0.734
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/computation-system-v2/README.md +152 -0
- package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +720 -0
- package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +176 -0
- package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +294 -0
- package/functions/computation-system-v2/computations/TestComputation.js +46 -0
- package/functions/computation-system-v2/computations/UserPortfolioSummary.js +172 -0
- package/functions/computation-system-v2/config/bulltrackers.config.js +317 -0
- package/functions/computation-system-v2/framework/core/Computation.js +73 -0
- package/functions/computation-system-v2/framework/core/Manifest.js +223 -0
- package/functions/computation-system-v2/framework/core/RuleInjector.js +53 -0
- package/functions/computation-system-v2/framework/core/Rules.js +231 -0
- package/functions/computation-system-v2/framework/core/RunAnalyzer.js +163 -0
- package/functions/computation-system-v2/framework/cost/CostTracker.js +154 -0
- package/functions/computation-system-v2/framework/data/DataFetcher.js +399 -0
- package/functions/computation-system-v2/framework/data/QueryBuilder.js +232 -0
- package/functions/computation-system-v2/framework/data/SchemaRegistry.js +287 -0
- package/functions/computation-system-v2/framework/execution/Orchestrator.js +498 -0
- package/functions/computation-system-v2/framework/execution/TaskRunner.js +35 -0
- package/functions/computation-system-v2/framework/execution/middleware/CostTrackerMiddleware.js +32 -0
- package/functions/computation-system-v2/framework/execution/middleware/LineageMiddleware.js +32 -0
- package/functions/computation-system-v2/framework/execution/middleware/Middleware.js +14 -0
- package/functions/computation-system-v2/framework/execution/middleware/ProfilerMiddleware.js +47 -0
- package/functions/computation-system-v2/framework/index.js +45 -0
- package/functions/computation-system-v2/framework/lineage/LineageTracker.js +147 -0
- package/functions/computation-system-v2/framework/monitoring/Profiler.js +80 -0
- package/functions/computation-system-v2/framework/resilience/Checkpointer.js +66 -0
- package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +327 -0
- package/functions/computation-system-v2/framework/storage/StateRepository.js +286 -0
- package/functions/computation-system-v2/framework/storage/StorageManager.js +469 -0
- package/functions/computation-system-v2/framework/storage/index.js +9 -0
- package/functions/computation-system-v2/framework/testing/ComputationTester.js +86 -0
- package/functions/computation-system-v2/framework/utils/Graph.js +205 -0
- package/functions/computation-system-v2/handlers/dispatcher.js +109 -0
- package/functions/computation-system-v2/handlers/index.js +23 -0
- package/functions/computation-system-v2/handlers/onDemand.js +289 -0
- package/functions/computation-system-v2/handlers/scheduler.js +327 -0
- package/functions/computation-system-v2/index.js +163 -0
- package/functions/computation-system-v2/rules/index.js +49 -0
- package/functions/computation-system-v2/rules/instruments.js +465 -0
- package/functions/computation-system-v2/rules/metrics.js +304 -0
- package/functions/computation-system-v2/rules/portfolio.js +534 -0
- package/functions/computation-system-v2/rules/rankings.js +655 -0
- package/functions/computation-system-v2/rules/social.js +562 -0
- package/functions/computation-system-v2/rules/trades.js +545 -0
- package/functions/computation-system-v2/scripts/migrate-sectors.js +73 -0
- package/functions/computation-system-v2/test/test-dispatcher.js +317 -0
- package/functions/computation-system-v2/test/test-framework.js +500 -0
- package/functions/computation-system-v2/test/test-real-execution.js +166 -0
- package/functions/computation-system-v2/test/test-real-integration.js +194 -0
- package/functions/computation-system-v2/test/test-refactor-e2e.js +131 -0
- package/functions/computation-system-v2/test/test-results.json +31 -0
- package/functions/computation-system-v2/test/test-risk-metrics-computation.js +329 -0
- package/functions/computation-system-v2/test/test-scheduler.js +204 -0
- package/functions/computation-system-v2/test/test-storage.js +449 -0
- package/functions/orchestrator/index.js +18 -26
- package/package.json +3 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Graph Algorithms Utility
|
|
3
|
+
* Provides generic implementations for graph operations:
|
|
4
|
+
* - Topological Sort (Kahn's Algorithm) with level/pass calculation
|
|
5
|
+
* - Cycle Detection (Tarjan's Algorithm)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class Graph {
|
|
9
|
+
/**
|
|
10
|
+
* Performs a topological sort on a directed acyclic graph (DAG).
|
|
11
|
+
* Computes 'pass' (level) numbers for parallel execution layers.
|
|
12
|
+
* @param {string[]} nodes - List of node identifiers
|
|
13
|
+
* @param {Map<string, string[]>} adjacency - Map of node -> [dependencies]
|
|
14
|
+
* @returns {Array<{id: string, pass: number}>} Sorted items with pass numbers
|
|
15
|
+
* @throws {Error} If a cycle is detected or dependencies are missing
|
|
16
|
+
*/
|
|
17
|
+
static topologicalSort(nodes, adjacency) {
|
|
18
|
+
// 1. Validate: Ensure all dependencies actually exist in the nodes list.
|
|
19
|
+
// This prevents "ghost dependencies" from breaking the sort logic silently.
|
|
20
|
+
const nodeSet = new Set(nodes);
|
|
21
|
+
for (const [node, deps] of adjacency) {
|
|
22
|
+
for (const dep of deps) {
|
|
23
|
+
if (!nodeSet.has(dep)) {
|
|
24
|
+
throw new Error(`Integrity Error: Node '${node}' depends on '${dep}', but '${dep}' is not in the node list.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const inDegree = new Map();
|
|
30
|
+
const reverseAdj = new Map(); // dependent -> [dependents that depend on it]
|
|
31
|
+
|
|
32
|
+
// Initialize
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
inDegree.set(node, 0);
|
|
35
|
+
if (!reverseAdj.has(node)) reverseAdj.set(node, []);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build graph properties
|
|
39
|
+
for (const [node, deps] of adjacency) {
|
|
40
|
+
// Adjacency is Node -> [Dependencies]
|
|
41
|
+
// So if A depends on B, the edge is B -> A
|
|
42
|
+
inDegree.set(node, deps.length);
|
|
43
|
+
|
|
44
|
+
for (const dep of deps) {
|
|
45
|
+
if (!reverseAdj.has(dep)) reverseAdj.set(dep, []);
|
|
46
|
+
reverseAdj.get(dep).push(node);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const queue = [];
|
|
51
|
+
const sorted = [];
|
|
52
|
+
const passes = new Map(); // node -> pass number
|
|
53
|
+
|
|
54
|
+
// 2. Enqueue 0-degree nodes (Pass 1)
|
|
55
|
+
for (const [node, degree] of inDegree) {
|
|
56
|
+
if (degree === 0) {
|
|
57
|
+
queue.push(node);
|
|
58
|
+
passes.set(node, 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Sort initial queue for deterministic behavior
|
|
63
|
+
queue.sort();
|
|
64
|
+
|
|
65
|
+
// 3. Process Queue
|
|
66
|
+
while (queue.length > 0) {
|
|
67
|
+
const current = queue.shift();
|
|
68
|
+
const currentPass = passes.get(current);
|
|
69
|
+
|
|
70
|
+
sorted.push({
|
|
71
|
+
id: current,
|
|
72
|
+
pass: currentPass
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Find nodes that depend on current
|
|
76
|
+
const dependents = reverseAdj.get(current) || [];
|
|
77
|
+
|
|
78
|
+
for (const dependent of dependents) {
|
|
79
|
+
const newDegree = inDegree.get(dependent) - 1;
|
|
80
|
+
inDegree.set(dependent, newDegree);
|
|
81
|
+
|
|
82
|
+
// Update pass: dependent must run after current
|
|
83
|
+
const existingPass = passes.get(dependent) || 0;
|
|
84
|
+
if (currentPass + 1 > existingPass) {
|
|
85
|
+
passes.set(dependent, currentPass + 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (newDegree === 0) {
|
|
89
|
+
queue.push(dependent);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Re-sort queue to ensure deterministic processing order within the same level
|
|
94
|
+
queue.sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 4. Verify Sort Integrity
|
|
98
|
+
if (sorted.length !== nodes.length) {
|
|
99
|
+
// Auto-diagnose: Try to find the cycle to give a helpful error
|
|
100
|
+
const cycle = Graph.detectCycle(nodes, adjacency);
|
|
101
|
+
if (cycle) {
|
|
102
|
+
throw new Error(`Graph cycle detected during sort: ${cycle}`);
|
|
103
|
+
}
|
|
104
|
+
throw new Error('Graph sort failed (unknown integrity error, possibly disconnected islands or ghost deps)');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return sorted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Detects cycles in a graph using an iterative version of Tarjan's SCC algorithm.
|
|
112
|
+
* Iterative implementation is used to avoid stack overflow on very deep dependency chains.
|
|
113
|
+
* @param {string[]} nodes - List of node identifiers
|
|
114
|
+
* @param {Map<string, string[]>} adjacency - Map of node -> [dependencies]
|
|
115
|
+
* @returns {string|null} Description of cycle if found, null otherwise
|
|
116
|
+
*/
|
|
117
|
+
static detectCycle(nodes, adjacency) {
|
|
118
|
+
let index = 0;
|
|
119
|
+
const indices = new Map();
|
|
120
|
+
const lowLinks = new Map();
|
|
121
|
+
const onStack = new Set();
|
|
122
|
+
const sccStack = [];
|
|
123
|
+
const nodeSet = new Set(nodes);
|
|
124
|
+
|
|
125
|
+
// Explicit stack for DFS state to avoid recursion limit
|
|
126
|
+
// State: { v: node, iter: iterator of neighbors, parent: node }
|
|
127
|
+
const workStack = [];
|
|
128
|
+
|
|
129
|
+
const getNeighbors = (node) => {
|
|
130
|
+
const deps = adjacency.get(node) || [];
|
|
131
|
+
return deps.filter(w => nodeSet.has(w))[Symbol.iterator]();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
if (indices.has(node)) continue;
|
|
136
|
+
|
|
137
|
+
// Initialize DFS for this component
|
|
138
|
+
workStack.push({ v: node, iter: getNeighbors(node), parent: null });
|
|
139
|
+
|
|
140
|
+
while (workStack.length > 0) {
|
|
141
|
+
const state = workStack[workStack.length - 1];
|
|
142
|
+
const { v, iter, parent } = state;
|
|
143
|
+
|
|
144
|
+
// First time visiting this node?
|
|
145
|
+
if (!indices.has(v)) {
|
|
146
|
+
indices.set(v, index);
|
|
147
|
+
lowLinks.set(v, index);
|
|
148
|
+
index++;
|
|
149
|
+
sccStack.push(v);
|
|
150
|
+
onStack.add(v);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const next = iter.next();
|
|
154
|
+
|
|
155
|
+
if (!next.done) {
|
|
156
|
+
const w = next.value;
|
|
157
|
+
if (!indices.has(w)) {
|
|
158
|
+
// Tree edge: Push child onto stack (simulate recursion)
|
|
159
|
+
workStack.push({ v: w, iter: getNeighbors(w), parent: v });
|
|
160
|
+
} else if (onStack.has(w)) {
|
|
161
|
+
// Back edge: Update lowLink immediately
|
|
162
|
+
lowLinks.set(v, Math.min(lowLinks.get(v), indices.get(w)));
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// All neighbors processed: Pop from work stack (simulate return)
|
|
166
|
+
workStack.pop();
|
|
167
|
+
|
|
168
|
+
// Propagate lowLink to parent (if any)
|
|
169
|
+
if (parent) {
|
|
170
|
+
lowLinks.set(parent, Math.min(lowLinks.get(parent), lowLinks.get(v)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check if v is a root of an SCC
|
|
174
|
+
if (lowLinks.get(v) === indices.get(v)) {
|
|
175
|
+
const scc = [];
|
|
176
|
+
let w;
|
|
177
|
+
do {
|
|
178
|
+
w = sccStack.pop();
|
|
179
|
+
onStack.delete(w);
|
|
180
|
+
scc.push(w);
|
|
181
|
+
} while (w !== v);
|
|
182
|
+
|
|
183
|
+
// If SCC has > 1 node, it's a cycle
|
|
184
|
+
if (scc.length > 1) {
|
|
185
|
+
return scc.join(' -> ') + ' -> ' + scc[0];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check for self-loop (single node cycle)
|
|
189
|
+
if (scc.length === 1) {
|
|
190
|
+
const self = scc[0];
|
|
191
|
+
const deps = adjacency.get(self) || [];
|
|
192
|
+
if (deps.includes(self)) {
|
|
193
|
+
return `${self} -> ${self}`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { Graph };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Computation Dispatcher
|
|
3
|
+
* * RESPONSIBILITIES:
|
|
4
|
+
* 1. Receive HTTP request (from Cloud Tasks or User)
|
|
5
|
+
* 2. Delegate execution to the Orchestrator
|
|
6
|
+
* 3. Translate Orchestrator STATUS (blocked, completed) into HTTP CODES (503, 200)
|
|
7
|
+
* * Why is this file so short?
|
|
8
|
+
* All validation logic (Schedule, Dependencies, Data) has moved to
|
|
9
|
+
* framework/core/RunAnalyzer.js to ensure consistency between
|
|
10
|
+
* the Scheduler and the Worker.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const system = require('../index');
|
|
14
|
+
|
|
15
|
+
exports.dispatcherHandler = async (req, res) => {
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const {
|
|
20
|
+
computationName,
|
|
21
|
+
targetDate,
|
|
22
|
+
source = 'scheduled', // 'scheduled' | 'on-demand'
|
|
23
|
+
entityIds, // Optional: specific entities
|
|
24
|
+
dryRun = false,
|
|
25
|
+
force = false // Optional: run even if hash matches
|
|
26
|
+
} = req.body || {};
|
|
27
|
+
|
|
28
|
+
// 1. Basic Validation
|
|
29
|
+
if (!computationName) {
|
|
30
|
+
return res.status(400).json({
|
|
31
|
+
status: 'error',
|
|
32
|
+
reason: 'MISSING_COMPUTATION',
|
|
33
|
+
message: 'computationName is required'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const date = targetDate || new Date().toISOString().split('T')[0];
|
|
38
|
+
console.log(`[Dispatcher] Received ${source} request: ${computationName} for ${date}`);
|
|
39
|
+
|
|
40
|
+
// 2. DELEGATE TO ORCHESTRATOR
|
|
41
|
+
// The Orchestrator calls RunAnalyzer internally to check:
|
|
42
|
+
// - Is it scheduled?
|
|
43
|
+
// - Are dependencies ready?
|
|
44
|
+
// - Is data available?
|
|
45
|
+
// - Has the code changed?
|
|
46
|
+
const result = await system.runComputation({
|
|
47
|
+
date,
|
|
48
|
+
computation: computationName,
|
|
49
|
+
entityIds,
|
|
50
|
+
dryRun
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const duration = Date.now() - startTime;
|
|
54
|
+
|
|
55
|
+
// 3. HANDLE SUCCESS (Completed or Skipped/Up-to-date)
|
|
56
|
+
if (result.status === 'completed' || result.status === 'skipped') {
|
|
57
|
+
console.log(`[Dispatcher] ${computationName} ${result.status}: ${result.resultCount || 0} entities in ${duration}ms`);
|
|
58
|
+
|
|
59
|
+
return res.status(200).json({
|
|
60
|
+
status: result.status,
|
|
61
|
+
computation: result.name,
|
|
62
|
+
date,
|
|
63
|
+
entityCount: result.resultCount,
|
|
64
|
+
duration,
|
|
65
|
+
hash: result.hash,
|
|
66
|
+
reason: result.reason
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 4. HANDLE BLOCKS (The Critical Retry Logic)
|
|
71
|
+
// If Orchestrator says "blocked", we MUST return 503 for Cloud Tasks to retry.
|
|
72
|
+
if (result.status === 'blocked' || result.status === 'impossible') {
|
|
73
|
+
console.log(`[Dispatcher] ${computationName} blocked: ${result.reason}`);
|
|
74
|
+
|
|
75
|
+
// Scheduled tasks need 503 to trigger retry
|
|
76
|
+
// On-demand users need 200 to see the error message immediately
|
|
77
|
+
const httpStatus = source === 'scheduled' ? 503 : 200;
|
|
78
|
+
|
|
79
|
+
return res.status(httpStatus).json({
|
|
80
|
+
status: result.status,
|
|
81
|
+
reason: result.reason,
|
|
82
|
+
message: `Computation blocked: ${result.reason}`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 5. Fallback for other statuses (e.g., 'not_scheduled')
|
|
87
|
+
return res.status(200).json(result);
|
|
88
|
+
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const duration = Date.now() - startTime;
|
|
91
|
+
console.error(`[Dispatcher] Error after ${duration}ms:`, error);
|
|
92
|
+
|
|
93
|
+
// Handle "Not Found" specifically
|
|
94
|
+
if (error.message && error.message.includes('Computation not found')) {
|
|
95
|
+
return res.status(400).json({
|
|
96
|
+
status: 'error',
|
|
97
|
+
reason: 'UNKNOWN_COMPUTATION',
|
|
98
|
+
message: error.message
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Return 500 to trigger Cloud Tasks retry for crash/network errors
|
|
103
|
+
return res.status(500).json({
|
|
104
|
+
status: 'error',
|
|
105
|
+
reason: 'EXECUTION_FAILED',
|
|
106
|
+
message: error.message
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Cloud Function Handlers
|
|
3
|
+
*
|
|
4
|
+
* Export handlers for deployment as Cloud Functions:
|
|
5
|
+
* - computeScheduler: Single scheduler triggered every minute
|
|
6
|
+
* - computeDispatcher: Receives tasks from Cloud Tasks queue
|
|
7
|
+
* - computeOnDemand: Receives requests from frontend
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { schedulerHandler } = require('./scheduler');
|
|
11
|
+
const { dispatcherHandler } = require('./dispatcher');
|
|
12
|
+
const { onDemandHandler } = require('./onDemand');
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
// Unified scheduler - triggered every minute by Cloud Scheduler
|
|
16
|
+
computeScheduler: schedulerHandler,
|
|
17
|
+
|
|
18
|
+
// Main dispatcher - handles scheduled tasks from Cloud Tasks
|
|
19
|
+
computeDispatcher: dispatcherHandler,
|
|
20
|
+
|
|
21
|
+
// On-demand API - handles frontend requests
|
|
22
|
+
computeOnDemand: onDemandHandler
|
|
23
|
+
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview On-Demand Computation API
|
|
3
|
+
*
|
|
4
|
+
* Frontend-facing endpoint for user-triggered computation requests.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* 1. Authenticate user (Firebase Auth / JWT)
|
|
8
|
+
* 2. Rate limit requests per user
|
|
9
|
+
* 3. Validate user can access requested entities
|
|
10
|
+
* 4. Forward to Dispatcher for validation and execution
|
|
11
|
+
*
|
|
12
|
+
* This is a lightweight gateway - all computation logic is in the Dispatcher.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { dispatcherHandler, initialize } = require('./dispatcher');
|
|
16
|
+
const config = require('../config/bulltrackers.config');
|
|
17
|
+
|
|
18
|
+
// Simple in-memory rate limiter (for Cloud Functions)
|
|
19
|
+
// In production, consider using Redis or Firestore for distributed rate limiting
|
|
20
|
+
const rateLimitStore = new Map();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Main on-demand handler.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} req - HTTP request
|
|
26
|
+
* @param {Object} res - HTTP response
|
|
27
|
+
*/
|
|
28
|
+
async function onDemandHandler(req, res) {
|
|
29
|
+
// Set CORS headers
|
|
30
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
31
|
+
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
32
|
+
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
33
|
+
|
|
34
|
+
// Handle preflight
|
|
35
|
+
if (req.method === 'OPTIONS') {
|
|
36
|
+
return res.status(204).send('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// 1. Authenticate user
|
|
41
|
+
const user = await authenticateRequest(req);
|
|
42
|
+
if (!user) {
|
|
43
|
+
return res.status(401).json({
|
|
44
|
+
status: 'error',
|
|
45
|
+
reason: 'UNAUTHORIZED',
|
|
46
|
+
message: 'Authentication required'
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Rate limit
|
|
51
|
+
const rateLimitResult = checkRateLimit(user.id);
|
|
52
|
+
if (!rateLimitResult.allowed) {
|
|
53
|
+
return res.status(429).json({
|
|
54
|
+
status: 'error',
|
|
55
|
+
reason: 'RATE_LIMITED',
|
|
56
|
+
message: 'Too many requests. Please wait.',
|
|
57
|
+
retryAfter: rateLimitResult.retryAfter
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { computation, date, entityIds, force } = req.body || {};
|
|
62
|
+
|
|
63
|
+
// 3. Validate request
|
|
64
|
+
if (!computation) {
|
|
65
|
+
return res.status(400).json({
|
|
66
|
+
status: 'error',
|
|
67
|
+
reason: 'MISSING_COMPUTATION',
|
|
68
|
+
message: 'computation field is required'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. Validate user can access requested entities
|
|
73
|
+
if (entityIds && entityIds.length > 0) {
|
|
74
|
+
const accessCheck = validateEntityAccess(user, entityIds);
|
|
75
|
+
if (!accessCheck.allowed) {
|
|
76
|
+
return res.status(403).json({
|
|
77
|
+
status: 'error',
|
|
78
|
+
reason: 'ACCESS_DENIED',
|
|
79
|
+
message: accessCheck.message
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. Check if computation is allowed for on-demand
|
|
85
|
+
const allowedComputations = config.onDemand?.allowedComputations;
|
|
86
|
+
if (allowedComputations && !allowedComputations.includes(computation)) {
|
|
87
|
+
return res.status(403).json({
|
|
88
|
+
status: 'error',
|
|
89
|
+
reason: 'COMPUTATION_NOT_ALLOWED',
|
|
90
|
+
message: `${computation} is not available for on-demand requests`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 6. Forward to Dispatcher
|
|
95
|
+
console.log(`[OnDemand] User ${user.id} requesting ${computation} for entities: ${entityIds?.join(', ') || 'all'}`);
|
|
96
|
+
|
|
97
|
+
// Create a mock request/response to call dispatcher
|
|
98
|
+
const dispatcherRequest = {
|
|
99
|
+
body: {
|
|
100
|
+
computationName: computation,
|
|
101
|
+
targetDate: date || getTodayDate(),
|
|
102
|
+
source: 'on-demand',
|
|
103
|
+
entityIds: entityIds,
|
|
104
|
+
force: force || false,
|
|
105
|
+
requestedBy: user.id
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Capture dispatcher response
|
|
110
|
+
const dispatcherResponse = await callDispatcher(dispatcherRequest);
|
|
111
|
+
|
|
112
|
+
// Forward response to client
|
|
113
|
+
return res.status(dispatcherResponse.status).json(dispatcherResponse.body);
|
|
114
|
+
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error('[OnDemand] Error:', error);
|
|
117
|
+
return res.status(500).json({
|
|
118
|
+
status: 'error',
|
|
119
|
+
reason: 'INTERNAL_ERROR',
|
|
120
|
+
message: 'An internal error occurred'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Authenticate the request.
|
|
127
|
+
* Returns user object or null if not authenticated.
|
|
128
|
+
*/
|
|
129
|
+
async function authenticateRequest(req) {
|
|
130
|
+
const authHeader = req.headers.authorization;
|
|
131
|
+
|
|
132
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const token = authHeader.substring(7);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
// Option 1: Firebase Admin SDK (if available)
|
|
140
|
+
if (global.firebaseAdmin) {
|
|
141
|
+
const decodedToken = await global.firebaseAdmin.auth().verifyIdToken(token);
|
|
142
|
+
return {
|
|
143
|
+
id: decodedToken.uid,
|
|
144
|
+
email: decodedToken.email,
|
|
145
|
+
type: decodedToken.user_type || 'unknown'
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Option 2: Simple JWT decode (for development/testing)
|
|
150
|
+
// In production, always verify with Firebase Admin
|
|
151
|
+
const payload = decodeJwt(token);
|
|
152
|
+
if (payload && payload.sub) {
|
|
153
|
+
return {
|
|
154
|
+
id: payload.sub,
|
|
155
|
+
email: payload.email,
|
|
156
|
+
type: payload.user_type || 'unknown'
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.warn('[OnDemand] Auth error:', error.message);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Simple JWT decode (without verification - for development only).
|
|
169
|
+
*/
|
|
170
|
+
function decodeJwt(token) {
|
|
171
|
+
try {
|
|
172
|
+
const parts = token.split('.');
|
|
173
|
+
if (parts.length !== 3) return null;
|
|
174
|
+
|
|
175
|
+
const payload = Buffer.from(parts[1], 'base64').toString('utf8');
|
|
176
|
+
return JSON.parse(payload);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check rate limit for a user.
|
|
184
|
+
*/
|
|
185
|
+
function checkRateLimit(userId) {
|
|
186
|
+
const maxRequests = config.onDemand?.maxRequestsPerMinute || 5;
|
|
187
|
+
const windowMs = 60 * 1000; // 1 minute
|
|
188
|
+
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const userKey = `ratelimit:${userId}`;
|
|
191
|
+
|
|
192
|
+
// Get or create user's request history
|
|
193
|
+
let history = rateLimitStore.get(userKey);
|
|
194
|
+
if (!history) {
|
|
195
|
+
history = [];
|
|
196
|
+
rateLimitStore.set(userKey, history);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Remove old entries
|
|
200
|
+
const cutoff = now - windowMs;
|
|
201
|
+
while (history.length > 0 && history[0] < cutoff) {
|
|
202
|
+
history.shift();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check limit
|
|
206
|
+
if (history.length >= maxRequests) {
|
|
207
|
+
const oldestRequest = history[0];
|
|
208
|
+
const retryAfter = Math.ceil((oldestRequest + windowMs - now) / 1000);
|
|
209
|
+
return { allowed: false, retryAfter };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Record this request
|
|
213
|
+
history.push(now);
|
|
214
|
+
|
|
215
|
+
return { allowed: true };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validate user can access the requested entities.
|
|
220
|
+
*/
|
|
221
|
+
function validateEntityAccess(user, entityIds) {
|
|
222
|
+
// Default policy: users can only access their own data
|
|
223
|
+
// This can be extended based on user roles
|
|
224
|
+
|
|
225
|
+
for (const entityId of entityIds) {
|
|
226
|
+
// Allow if entityId matches user's ID
|
|
227
|
+
if (entityId === user.id) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Allow if user is admin (could add role checking)
|
|
232
|
+
// if (user.role === 'admin') continue;
|
|
233
|
+
|
|
234
|
+
// Deny access
|
|
235
|
+
return {
|
|
236
|
+
allowed: false,
|
|
237
|
+
message: `You cannot access data for entity: ${entityId}`
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { allowed: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Call the dispatcher and capture response.
|
|
246
|
+
*/
|
|
247
|
+
async function callDispatcher(req) {
|
|
248
|
+
// Initialize dispatcher if needed
|
|
249
|
+
await initialize();
|
|
250
|
+
|
|
251
|
+
// Create a mock response object to capture the dispatcher's response
|
|
252
|
+
let responseStatus = 200;
|
|
253
|
+
let responseBody = null;
|
|
254
|
+
|
|
255
|
+
const mockRes = {
|
|
256
|
+
status: (code) => {
|
|
257
|
+
responseStatus = code;
|
|
258
|
+
return mockRes;
|
|
259
|
+
},
|
|
260
|
+
json: (body) => {
|
|
261
|
+
responseBody = body;
|
|
262
|
+
return mockRes;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
await dispatcherHandler(req, mockRes);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
status: responseStatus,
|
|
270
|
+
body: responseBody
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get today's date in YYYY-MM-DD format.
|
|
276
|
+
*/
|
|
277
|
+
function getTodayDate() {
|
|
278
|
+
return new Date().toISOString().split('T')[0];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Export for Cloud Functions
|
|
282
|
+
module.exports = {
|
|
283
|
+
onDemandHandler,
|
|
284
|
+
|
|
285
|
+
// For testing
|
|
286
|
+
_authenticateRequest: authenticateRequest,
|
|
287
|
+
_checkRateLimit: checkRateLimit,
|
|
288
|
+
_validateEntityAccess: validateEntityAccess
|
|
289
|
+
};
|